|
""" |
|
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://cdn.awesome-panel.org/resources/commuting_flows_italy/edges.json", |
|
orient="split", |
|
dtype=EDGES_DTYPES, |
|
) |
|
edges_df = get_edges_df() |
|
|
|
|
|
@pn.cache |
|
def get_nodes_df(): |
|
return pd.read_json( |
|
"https://cdn.awesome-panel.org/resources/commuting_flows_italy/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://cdn.awesome-panel.org/resources/commuting_flows_italy/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://cdn.awesome-panel.org/resources/commuting_flows_italy/home_work.svg", |
|
title=DASH_TITLE, |
|
theme="default", |
|
theme_toggle=False, |
|
accent=ACCENT, |
|
neutral_color="white", |
|
main=[layout], |
|
main_max_width="1000px", |
|
).servable() |