| """ |
| Source: https://awesome-panel.org/resources/commuting_flows_italian_regions/ |
| """ |
| import holoviews as hv |
| import numpy as np |
| import pandas as pd |
| import panel as pn |
| from bokeh.models import HoverTool |
| from shapely.geometry import LineString |
|
|
| |
| hv.extension("bokeh") |
|
|
| |
| pn.extension(sizing_mode="stretch_width") |
|
|
| |
| DASH_TITLE = "Commuting flows between Italian Regions" |
|
|
| |
| ACCENT = "#2f4f4f" |
| INCOMING_COLOR = "rgba(0, 108, 151, 0.75)" |
| OUTGOING_COLOR = "rgba(199, 81, 51, 0.75)" |
| INTERNAL_COLOR = "rgba(47, 79, 79, 0.55)" |
|
|
| |
| DEFAULT_COLOR = "white" |
| TITLE_SIZE = "18pt" |
| FONT_SIZE = "20pt" |
|
|
| |
| MIN_PT_SIZE = 7 |
| MAX_PT_SIZE = 10 |
|
|
| |
| MIN_LW = 1 |
| MAX_LW = 10 |
|
|
| |
| ITA_REGIONS_DTYPES = { |
| "cod_reg": "uint8", |
| "den_reg": "object", |
| "x": "object", |
| "y": "object", |
| } |
|
|
| NODES_DTYPES = { |
| "cod_reg": "uint8", |
| "x": "float64", |
| "y": "float64", |
| } |
|
|
| EDGES_DTYPES = { |
| "motivo": "object", |
| "interno": "bool", |
| "flussi": "uint32", |
| "reg_o": "uint8", |
| "reg_d": "uint8", |
| "x_o": "float64", |
| "y_o": "float64", |
| "x_d": "float64", |
| "y_d": "float64", |
| } |
|
|
| |
| ITA_REGIONS = { |
| 1: "Piemonte", |
| 2: "Valle d'Aosta/Vallée d'Aoste", |
| 3: "Lombardia", |
| 4: "Trentino-Alto Adige/Südtirol", |
| 5: "Veneto", |
| 6: "Friuli-Venezia Giulia", |
| 7: "Liguria", |
| 8: "Emilia-Romagna", |
| 9: "Toscana", |
| 10: "Umbria", |
| 11: "Marche", |
| 12: "Lazio", |
| 13: "Abruzzo", |
| 14: "Molise", |
| 15: "Campania", |
| 16: "Puglia", |
| 17: "Basilicata", |
| 18: "Calabria", |
| 19: "Sicilia", |
| 20: "Sardegna", |
| } |
|
|
| |
| COMMUTING_PURPOSE = { |
| "Work": "Lavoro", |
| "Study": "Studio", |
| "Total": "Totale", |
| } |
|
|
| |
| DASH_DESCR = f""" |
| <div> |
| <hr /> |
| <p>A Panel dashboard showing <b style="color:{INCOMING_COLOR};">incoming</b> |
| and <b style="color:{OUTGOING_COLOR};">outgoing</b> commuting flows |
| for work and study between Italian Regions.</p> |
| <p>The width of the curves reflects the magnitude of the flows.</p> |
| <p> |
| <a href="https://www.istat.it/it/archivio/139381" target="_blank">Commuting data</a> from the |
| 15th Population and Housing Census (Istat, 2011). |
| </p> |
| <p> |
| <a href="https://www.istat.it/it/archivio/222527" target="_blank">Administrative boundaries</a> from |
| ISTAT. |
| </p> |
| <hr /> |
| </div> |
| """ |
|
|
| CSS_FIX = """ |
| :host(.outline) .bk-btn.bk-btn-primary.bk-active, :host(.outline) .bk-btn.bk-btn-primary:active { |
| color: var(--foreground-on-accent-rest) !important; |
| } |
| """ |
|
|
| if not CSS_FIX in pn.config.raw_css: |
| pn.config.raw_css.append(CSS_FIX) |
|
|
|
|
| def get_incoming_numind(edges, region_code, comm_purpose): |
| """ |
| Returns the total incoming commuters to the selected Region. |
| """ |
|
|
| |
| if comm_purpose == "Totale": |
| query = f"reg_d == {region_code} & interno == 0" |
| else: |
| query = f"(reg_d == {region_code} & motivo == '{comm_purpose}' & interno == 0)" |
|
|
| flows = edges.query(query)["flussi"].sum() |
|
|
| return pn.indicators.Number( |
| name="Incoming", |
| value=flows, |
| default_color=DEFAULT_COLOR, |
| styles={"background": INCOMING_COLOR, "padding": "5px 10px 5px 10px", "border-radius": "5px"}, |
| title_size=TITLE_SIZE, |
| font_size=FONT_SIZE, |
| sizing_mode="stretch_width", |
| align="center", |
| css_classes=["center_number"], |
| ) |
|
|
|
|
| def get_outgoing_numind(edges, region_code, comm_purpose): |
| """ |
| Returns the outgoing commuters from |
| the selected Region. |
| """ |
|
|
| |
| if comm_purpose == "Totale": |
| query = f"reg_o == {region_code} & interno == 0" |
| else: |
| query = f"(reg_o == {region_code} & motivo == '{comm_purpose}' & interno == 0)" |
|
|
| flows = edges.query(query)["flussi"].sum() |
|
|
| return pn.indicators.Number( |
| name="Outgoing", |
| value=flows, |
| default_color=DEFAULT_COLOR, |
| styles={"background": OUTGOING_COLOR, "padding": "5px 10px 5px 10px", "border-radius": "5px"}, |
| title_size=TITLE_SIZE, |
| font_size=FONT_SIZE, |
| sizing_mode="stretch_width", |
| align="center", |
| css_classes=["center_number"], |
| ) |
|
|
|
|
| def get_internal_numind(edges, region_code, comm_purpose): |
| """ |
| Returns the number of internal commuters of |
| the selected Region. |
| """ |
|
|
| |
| if comm_purpose == "Totale": |
| query = f"reg_o == {region_code} & interno == 1" |
| else: |
| query = f"(reg_o == {region_code} & motivo == '{comm_purpose}' & interno == 1)" |
|
|
| flows = edges.query(query)["flussi"].sum() |
|
|
| return pn.indicators.Number( |
| name="Internal mobility", |
| value=flows, |
| default_color=DEFAULT_COLOR, |
| styles={"background": INTERNAL_COLOR, "padding": "5px 10px 5px 10px", "border-radius": "5px"}, |
| title_size=TITLE_SIZE, |
| font_size=FONT_SIZE, |
| sizing_mode="stretch_width", |
| align="center", |
| css_classes=["center_number"], |
| ) |
|
|
|
|
| def filter_edges(edges, region_code, comm_purpose): |
| """ |
| This function filters the rows of the edges for |
| the selected Region and commuting purpose. |
| """ |
|
|
| if comm_purpose == "Totale": |
| query = f"(reg_o == {region_code} & interno == 0) |" |
| query += f" (reg_d == {region_code} & interno == 0)" |
| else: |
| query = f"(reg_o == {region_code} & motivo == '{comm_purpose}' & interno == 0) |" |
| query += f" (reg_d == {region_code} & motivo == '{comm_purpose}' & interno == 0)" |
| return edges.query(query) |
|
|
|
|
| def get_nodes(nodes, edges, region_code, comm_purpose): |
| """ |
| Get the graph's nodes for the selected Region and commuting purpose |
| """ |
|
|
| |
| filt_edges = filter_edges(edges, region_code, comm_purpose) |
|
|
| |
| region_codes = np.unique(filt_edges[["reg_o", "reg_d"]].values) |
|
|
| |
| nodes = nodes[nodes["cod_reg"].isin(region_codes)] |
|
|
| |
| nodes = nodes[["x", "y", "cod_reg"]] |
|
|
| |
| nodes["size"] = np.where( |
| nodes["cod_reg"] == region_code, MAX_PT_SIZE, MIN_PT_SIZE |
| ) |
|
|
| |
| nodes["marker"] = np.where( |
| nodes["cod_reg"] == region_code, "square", "circle" |
| ) |
|
|
| return nodes |
|
|
|
|
| def get_bezier_curve(x_o, y_o, x_d, y_d, steps=25): |
| """ |
| Draw a Bézier curve defined by a start point, endpoint and a control points |
| Source: https://stackoverflow.com/questions/69804595/trying-to-make-a-bezier-curve-on-pygame-library |
| """ |
|
|
| |
| od_line = LineString([(x_o, y_o), (x_d, y_d)]) |
|
|
| |
| offset_distance = od_line.length / 2 |
|
|
| |
| offset_pline = od_line.parallel_offset(offset_distance, "left") |
|
|
| |
| ctrl_x = offset_pline.centroid.x |
| ctrl_y = offset_pline.centroid.y |
|
|
| |
| t = np.array([i * 1 / steps for i in range(0, steps + 1)]) |
| x_coords = x_o * (1 - t) ** 2 + 2 * (1 - t) * t * ctrl_x + x_d * t**2 |
| y_coords = y_o * (1 - t) ** 2 + 2 * (1 - t) * t * ctrl_y + y_d * t**2 |
|
|
| return (x_coords, y_coords) |
|
|
|
|
| def get_edge_width(flow, min_flow, max_flow): |
| """ |
| This function calculates the width of the curves |
| according to the magnitude of the flow. |
| """ |
|
|
| return MIN_LW + np.power(flow - min_flow, 0.57) * ( |
| MAX_LW - MIN_LW |
| ) / np.power(max_flow - min_flow, 0.57) |
|
|
|
|
| def get_edges(nodes, edges, region_code, comm_purpose): |
| """ |
| Get the graph's edges for the selected Region and commuting purpose |
| """ |
|
|
| |
| filt_edges = filter_edges(edges, region_code, comm_purpose).copy() |
|
|
| |
| if comm_purpose == "Totale": |
| filt_edges = ( |
| filt_edges.groupby(["reg_o", "reg_d"]) |
| .agg( |
| motivo=("motivo", "first"), |
| interno=("interno", "first"), |
| flussi=("flussi", "sum"), |
| ) |
| .reset_index() |
| ) |
|
|
| |
| filt_edges.loc[:,"den_reg_o"] = filt_edges["reg_o"].map(ITA_REGIONS) |
| filt_edges.loc[:,"den_reg_d"] = filt_edges["reg_d"].map(ITA_REGIONS) |
|
|
| |
| filt_edges = filt_edges.merge( |
| nodes.add_suffix("_o"), left_on="reg_o", right_on="cod_reg_o" |
| ) |
|
|
| |
| filt_edges = filt_edges.merge( |
| nodes.add_suffix("_d"), left_on="reg_d", right_on="cod_reg_d" |
| ) |
|
|
| |
| filt_edges["curve"] = filt_edges.apply( |
| lambda row: get_bezier_curve( |
| row["x_o"], row["y_o"], row["x_d"], row["y_d"] |
| ), |
| axis=1, |
| ) |
|
|
| |
| min_flow = filt_edges["flussi"].min() |
| max_flow = filt_edges["flussi"].max() |
|
|
| |
| filt_edges["width"] = filt_edges.apply( |
| lambda row: get_edge_width( |
| row["flussi"], |
| min_flow, |
| max_flow, |
| ), |
| axis=1, |
| ) |
|
|
| |
| filt_edges["color"] = np.where( |
| filt_edges["reg_d"] == region_code, INCOMING_COLOR, OUTGOING_COLOR |
| ) |
|
|
| filt_edges = filt_edges.sort_values(by="flussi") |
|
|
| return filt_edges |
|
|
|
|
| def get_flow_map(nodes, edges, region_admin_bounds, region_code, comm_purpose): |
| """ |
| Returns a Graph showing incoming and outgoing commuting flows |
| for the selected Region and commuting purpose. |
| """ |
|
|
| def hook(plot, element): |
| """ |
| Custom hook for disabling x/y tick lines/labels |
| """ |
| plot.state.xaxis.major_tick_line_color = None |
| plot.state.xaxis.minor_tick_line_color = None |
| plot.state.xaxis.major_label_text_font_size = "0pt" |
| plot.state.yaxis.major_tick_line_color = None |
| plot.state.yaxis.minor_tick_line_color = None |
| plot.state.yaxis.major_label_text_font_size = "0pt" |
|
|
| |
| flow_map_hover = HoverTool( |
| tooltips=[ |
| ("Origin", "@den_reg_o"), |
| ("Destination", "@den_reg_d"), |
| ("Commuters", "@flussi"), |
| ] |
| ) |
|
|
| |
| region_graph_nodes = get_nodes(nodes, edges, region_code, comm_purpose) |
|
|
| |
| region_graph_edges = get_edges(nodes, edges, region_code, comm_purpose) |
|
|
| |
| curves = region_graph_edges["curve"].to_list() |
|
|
| |
| region_admin_bound = region_admin_bounds[ |
| (region_admin_bounds["cod_reg"] == region_code) |
| ].to_dict("records") |
|
|
| |
| region_admin_bound_path = hv.Path(region_admin_bound) |
| region_admin_bound_path.opts(color=ACCENT, line_width=1.0) |
|
|
| |
| region_flow_graph = hv.Graph( |
| (region_graph_edges.drop("curve", axis=1), region_graph_nodes, curves) |
| ) |
|
|
| |
| region_flow_graph.opts( |
| title="Incoming and outgoing commuting flows", |
| xlabel="", |
| ylabel="", |
| node_color="white", |
| node_hover_fill_color="magenta", |
| node_line_color=ACCENT, |
| node_size="size", |
| node_marker="marker", |
| edge_color="color", |
| edge_hover_line_color="magenta", |
| edge_line_width="width", |
| inspection_policy="edges", |
| tools=[flow_map_hover], |
| hooks=[hook], |
| frame_height=500, |
| ) |
|
|
| |
| flow_map = ( |
| hv.element.tiles.CartoLight() |
| * region_admin_bound_path |
| * region_flow_graph |
| ) |
|
|
| return flow_map |
|
|
|
|
| |
| @pn.cache |
| def get_edges_df(): |
| return pd.read_json( |
| "https://huggingface.co/spaces/awesome-panel/commuting_flows_italy/resolve/main/edges.json", |
| orient="split", |
| dtype=EDGES_DTYPES, |
| ) |
| edges_df = get_edges_df() |
|
|
| |
| @pn.cache |
| def get_nodes_df(): |
| return pd.read_json( |
| "https://huggingface.co/spaces/awesome-panel/commuting_flows_italy/resolve/main/nodes.json", |
| orient="split", |
| dtype=NODES_DTYPES, |
| ) |
|
|
| nodes_df = get_nodes_df() |
|
|
| |
| @pn.cache |
| def get_region_admin_bounds_df(): |
| return pd.read_json( |
| "https://huggingface.co/spaces/awesome-panel/commuting_flows_italy/resolve/main/italian_regions.json", |
| orient="split", |
| dtype=ITA_REGIONS_DTYPES, |
| ) |
| region_admin_bounds_df = get_region_admin_bounds_df() |
|
|
| |
| region_options = dict(map(reversed, ITA_REGIONS.items())) |
| region_options = dict(sorted(region_options.items())) |
|
|
| region_select = pn.widgets.Select( |
| name="Region:", |
| options=region_options, |
| sizing_mode="stretch_width", |
| ) |
|
|
| |
| purpose_select = pn.widgets.ToggleGroup( |
| name="", |
| options=COMMUTING_PURPOSE, |
| behavior="radio", |
| sizing_mode="stretch_width", |
| button_type="primary", button_style="outline" |
| ) |
|
|
| |
| descr_pane = pn.pane.HTML(DASH_DESCR, styles={"text-align": "left"}) |
|
|
| |
| incoming_numind_bind = pn.bind( |
| get_incoming_numind, |
| edges=edges_df, |
| region_code=region_select, |
| comm_purpose=purpose_select, |
| ) |
|
|
| |
| outgoing_numind_bind = pn.bind( |
| get_outgoing_numind, |
| edges=edges_df, |
| region_code=region_select, |
| comm_purpose=purpose_select, |
| ) |
|
|
| |
| internal_numind_bind = pn.bind( |
| get_internal_numind, |
| edges=edges_df, |
| region_code=region_select, |
| comm_purpose=purpose_select, |
| ) |
|
|
| |
| flowmap_bind = pn.bind( |
| get_flow_map, |
| nodes=nodes_df, |
| edges=edges_df, |
| region_admin_bounds=region_admin_bounds_df, |
| region_code=region_select, |
| comm_purpose=purpose_select, |
| ) |
|
|
| |
| layout = pn.Row( |
| pn.Column( |
| region_select, |
| purpose_select, |
| pn.Row(incoming_numind_bind, outgoing_numind_bind), |
| internal_numind_bind, |
| descr_pane, |
| width=350, |
| ), |
| flowmap_bind, |
| ) |
|
|
| pn.template.FastListTemplate( |
| site="", |
| logo="https://huggingface.co/spaces/awesome-panel/commuting_flows_italy/resolve/main/home_work.svg", |
| title=DASH_TITLE, |
| theme="default", |
| theme_toggle=False, |
| accent=ACCENT, |
| neutral_color="white", |
| main=[layout], |
| main_max_width="1000px", |
| ).servable() |