import os, json, math, pandas as pd
import plotly.graph_objects as go
from dash import Dash, dcc, html, Input, Output, State
import dash_mantine_components as dmc
import plotly.express as px
import plotly.io as pio
from dash_iconify import DashIconify
def load_cfg():
default_cfg = {
"fields": {
"id": "id",
"name": "name",
"office": "office",
"lat": "lat",
"lon": "lon",
"state": "state",
"role": "role",
"is_approved": "is_approved",
"list_membership": "list_membership",
"photo": "photo"
},
"map": {
"initial_view": {"lat": 39.8, "lon": -98.6, "zoom": 4}
},
"tooltip": {"fields": ["name", "office", "state"]},
"export": {"pdf_filename": "us_map.pdf"}
}
path = os.getenv("MAP_CONFIG", "map_config.yaml")
try:
import yaml
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or default_cfg
except Exception:
pass
return default_cfg
CFG = load_cfg()
F = CFG["fields"]
DATA_PATH = "data/hubspot_land_usa.csv"
if not os.path.exists(DATA_PATH):
DATA_PATH = "data/hubspot_5k.csv"
df = pd.read_csv(DATA_PATH) if os.path.exists(DATA_PATH) else pd.DataFrame({
F["id"]: [], F["name"]: [], F["office"]: [], F["lat"]: [], F["lon"]: [], F.get("state","state"): []
})
aliases = {
"id": ["id", "professional_id", "person_id"],
"name": ["name", "professional_name", "full_name", "person_name"],
"office": ["office", "office_name"],
"lat": ["lat", "latitude", "Lat", "LAT"],
"lon": ["lon", "lng", "longitude", "Lon", "LON"],
"state": ["state", "State"],
"role": ["role", "title", "position"],
"is_approved": ["is_approved", "approved", "isApproved"],
"list_membership": ["list_membership","lists","groups"],
"photo": ["photo_url", "image_url", "avatar", "picture", "photo", "image"],
}
def resolve(col_key):
col = F.get(col_key)
if isinstance(df, pd.DataFrame) and col in df.columns:
return col
for c in aliases.get(col_key, []):
if c in df.columns:
return c
return col
for k in list(aliases.keys()):
F[k] = resolve(k)
if isinstance(df, pd.DataFrame) and F.get("is_approved") in df.columns:
df[F["is_approved"]] = df[F["is_approved"]].astype(str).str.upper().isin(["TRUE","1","YES","Y","T"])
sep = "|"
if isinstance(df, pd.DataFrame) and F.get("list_membership") in df.columns:
df["__list_list__"] = df[F["list_membership"]].fillna("").apply(lambda s: [x.strip() for x in str(s).split(sep) if x and str(x).strip()])
else:
df = df.copy()
df["__list_list__"] = [[] for _ in range(len(df))]
def _opts(series):
if not isinstance(df, pd.DataFrame) or series not in df.columns: return []
return sorted([x for x in df[series].dropna().unique().tolist() if str(x).strip() != ""])
state_opts = _opts(F.get("state","state"))
role_opts = _opts(F.get("role","role"))
list_opts = sorted({x for items in df["__list_list__"] for x in items}) if len(df) else []
app = Dash(__name__, suppress_callback_exceptions=True)
server = app.server
header = dmc.AppShellHeader(
dmc.Group([ dmc.Text("US Professionals Map (PyCafe)", fw=700, size="lg") ], justify="space-between", align="center", h="100%"),
withBorder=True, h=60, px="md",
)
filters_row = dmc.Group(
[
dmc.MultiSelect(data=state_opts, placeholder="State", id="f_state", clearable=True, w=250),
dmc.MultiSelect(data=role_opts, placeholder="Role", id="f_role", clearable=True, w=250),
dmc.MultiSelect(data=list_opts, placeholder="List membership", id="f_list", clearable=True, w=280),
dmc.TextInput(id="f_name", placeholder="Search name...", w=250),
dmc.Button("Apply Filters", id="btn_apply", leftSection=DashIconify(icon="uil:setting", width=18, height=18)),
],
wrap="wrap", gap="sm", align="center",
)
row2_left = dmc.Group(
[
dmc.Switch(label="Approved only", id="f_approved", checked=False),
dmc.Switch(label="Enable clustering", id="f_cluster", checked=True),
dmc.SegmentedControl(
id="f_style", value="open-street-map",
data=[
{"label":"OSM","value":"open-street-map"},
{"label":"Carto (light)","value":"carto-positron"},
{"label":"Carto (dark)","value":"carto-darkmatter"},
],
w=360, style={"zIndex": 2},
),
],
wrap="wrap", gap="sm", align="center",
)
row2_right = dmc.Group(
[ dmc.Button("Export PDF", id="btn_pdf", variant="outline"), dmc.Text(id="pdf_status", size="sm", c="dimmed") ],
gap="sm", align="center",
)
controls = dmc.Container(
dmc.Stack([
filters_row,
dmc.Group([row2_left, row2_right], justify="space-between", align="center", grow=True),
dmc.Text(id="meta", size="sm", c="dimmed"),
], gap="xs"),
fluid=True, px="md", py="sm",
)
content_desktop = dmc.Box(
style={"display": "grid", "gridTemplateColumns": "5fr 1fr", "gap": "16px", "flex": "1 1 auto", "minHeight": 0},
visibleFrom="lg",
children=[
dmc.Paper(withBorder=True, shadow="xs", radius="md", p=0, style={"height": "100%", "overflow": "hidden"},
children=dcc.Graph(id="map", style={"height": "100%", "width": "100%", "display": "block"},
config={"scrollZoom": True, "displaylogo": False, "responsive": True})),
dmc.Paper(id="detail_panel", withBorder=True, shadow="xs", radius="md",
style={"height": "100%", "padding": "12px", "overflowY": "auto"},
children=[ dmc.Text("Details", fw=600, size="lg"), dmc.Divider(my="sm"),
dmc.Text("Click on point to show detail information", c="dimmed", size="sm") ]),
],
)
content_mobile = dmc.Stack(
[
dmc.Paper(withBorder=True, shadow="xs", radius="md", p=0,
style={"height": "100%", "overflow": "hidden"},
children=dcc.Graph(id="map_m", style={"height": "100%", "width": "100%", "display": "block"},
config={"scrollZoom": True, "displaylogo": False, "responsive": True})),
dmc.Paper(id="detail_panel_m", withBorder=True, shadow="xs", radius="md",
style={"minHeight":"30vh", "padding":"12px", "overflowY":"auto"},
children=[ dmc.Text("Details", fw=600, size="lg"), dmc.Divider(my="sm"),
dmc.Text("Click on point to show detail information", c="dimmed", size="sm") ]),
],
gap="md", hiddenFrom="lg", style={"flex": "1 1 auto", "minHeight": 0},
)
app.layout = dmc.MantineProvider(
theme={"colorScheme":"light", "fontFamily":"Inter, system-ui, Segoe UI, Roboto"},
children=dmc.AppShell(
children=[
header,
dmc.AppShellMain([ controls, content_desktop, content_mobile ],
pt=70, style={"height": "calc(100vh - 70px)", "display": "flex",
"flexDirection": "column", "overflow": "hidden"})
]
)
)
def apply_filters(state, role, lists, name, approved_only):
q = df.copy()
if len(q) and state and F.get("state") in q.columns: q = q[q[F["state"]].isin(state)]
if len(q) and role and F.get("role") in q.columns: q = q[q[F["role"]].isin(role)]
if len(q) and lists: q = q[q["__list_list__"].apply(lambda L: any(x in L for x in lists))]
if len(q) and approved_only and F.get("is_approved") in q.columns: q = q[q[F["is_approved"]] == True]
if len(q) and name and str(name).strip() and F.get("name") in q.columns:
key = str(name).strip().lower()
q = q[q[F["name"]].str.lower().str.contains(key, na=False)]
return q
def build_detail_card(row, big_photo=True):
name = str(row.get(F.get("name",""), "") or "")
role = str(row.get(F.get("role",""), "") or "")
office = str(row.get(F.get("office",""), "") or "")
state = str(row.get(F.get("state",""), "") or "")
approved = str(row.get(F.get("is_approved",""), "") or "")
lists = str(row.get(F.get("list_membership",""), "") or "")
photo = str(row.get(F.get("photo",""), "") or "")
blocks = []
if big_photo and photo.startswith(("http://","https://")):
blocks.append(html.Img(src=photo, style={"width":"100%","borderRadius":"12px","marginBottom":"10px"}))
else:
blocks.append(dmc.Group([
dmc.Avatar(src=photo if photo.startswith(("http://","https://")) else None,
alt=name[:1].upper() if name else "P", radius="xl", size=64,
children=None if photo.startswith(("http://","https://")) else dmc.Text((name[:1] if name else "P").upper(), fw=700)),
dmc.Stack([ dmc.Text(name, fw=700, size="lg"), dmc.Text(role, c="dimmed", size="sm") ], gap=0)
], align="center", gap="sm"))
blocks.extend([ dmc.Divider(my="sm"), dmc.Text(name, fw=700, size="lg"), dmc.Text(role, c="dimmed", size="sm"),
dmc.Space(h=6), dmc.Text(f"Office: {office}" if office else "Office: —"),
dmc.Text(f"State: {state}" if state else "State: —"),
dmc.Text(f"Approved: {approved}" if approved else "Approved: —"),
dmc.Text(f"Lists: {lists}" if lists else "Lists: —") ])
return dmc.Stack(blocks, gap="xs")
def nearest_row(q, lat, lon, tol_deg=0.02):
if q.empty: return None, None
if F["lat"] not in q.columns or F["lon"] not in q.columns: return None, None
d2 = (q[F["lat"]] - lat)**2 + (q[F["lon"]] - lon)**2
idx = d2.idxmin()
dist = math.sqrt(d2.loc[idx])
return (q.loc[idx], dist) if dist <= tol_deg else (None, None)
@app.callback(
Output("map","figure"),
Output("meta","children"),
Input("btn_apply", "n_clicks"),
Input("f_approved","checked"),
Input("f_cluster","checked"),
Input("f_style","value"),
State("f_state","value"),
State("f_role","value"),
State("f_list","value"),
State("f_name","value"),
)
def update_map(n_clicks, approved_only, use_cluster, style, state, role, lists, name):
approved_only = bool(approved_only)
use_cluster = True if use_cluster is None else bool(use_cluster)
style = style or "open-street-map"
q = apply_filters(state, role, lists, name, approved_only) if n_clicks else df
init = CFG["map"]["initial_view"]
z = init["zoom"]; center = {"lat": init["lat"], "lon": init["lon"]}
if use_cluster:
fig = px.scatter_map(q, lat=F["lat"], lon=F["lon"], hover_data=None)
fig.update_traces(marker={"size":6}, hovertext=q.apply(lambda r: f"{r.get(F['name'],'')} — {r.get(F['office'],'')}", axis=1), hoverinfo="text", cluster=dict(enabled=True))
else:
fig = px.scatter_map(q, lat=F["lat"], lon=F["lon"], hover_data=CFG["tooltip"]["fields"])
fig.update_traces(marker={"size":7, "opacity":0.9})
fig.update_layout(map=dict(style=style, center=center, zoom=z), margin=dict(l=0,r=0,t=0,b=0), clickmode="event+select")
meta = f"Points: {len(q):,} / {len(df):,} • Style: {style} • Cluster: {use_cluster} • Approved: {approved_only}"
return fig, meta
@app.callback(Output("map_m", "figure"), Input("map", "figure"), prevent_initial_call=False)
def sync_mobile_figure(fig):
return fig if fig is not None else go.Figure()
@app.callback(
Output("detail_panel","children"),
Output("detail_panel_m","children"),
Input("map","clickData"),
Input("map_m","clickData"),
State("f_state","value"),
State("f_role","value"),
State("f_list","value"),
State("f_name","value"),
State("f_approved","checked"),
State("f_cluster","checked"),
prevent_initial_call=True
)
def show_details(click_d, click_m, state, role, lists, name, approved_only, use_cluster):
header = [dmc.Text("Details", fw=600, size="lg"), dmc.Divider(my="sm")]
click = click_d or click_m
if not click or "points" not in click:
empty = header + [dmc.Text("Click on point to show information", c="dimmed", size="sm")]
return empty, empty
pt = click["points"][0]; lat = pt.get("lat"); lon = pt.get("lon")
if lat is None or lon is None:
err = header + [dmc.Text("Không xác định được điểm vừa chọn.", c="red")]
return err, err
q = apply_filters(state, role, lists, name, bool(approved_only))
tol = 0.08 if click_m else 0.03
row, dist = nearest_row(q, lat, lon, tol_deg=tol)
if row is None:
if use_cluster:
msg = header + [dmc.Text("Bạn đã chạm vào 1 cụm. Hãy zoom gần hoặc tắt Cluster để chọn điểm lẻ.", c="dimmed")]
return msg, msg
msg = header + [dmc.Text("Không tìm thấy bản ghi tương ứng.", c="red")]
return msg, msg
card = build_detail_card(row, big_photo=True)
detail = header + [card]
return detail, detail
@app.callback(Output("pdf_status","children"), Input("btn_pdf","n_clicks"), State("map","figure"), prevent_initial_call=True)
def export_pdf(_, fig_json):
try:
fig = pio.from_json(json.dumps(fig_json))
out_name = CFG.get("export",{}).get("pdf_filename","us_map.pdf")
pio.write_image(fig, out_name, format="pdf", width=1400, height=900, scale=1)
return f"Exported: {out_name}"
except Exception as e:
return f"Export failed: {e}"
if __name__ == "__main__":
app.run(debug=True, port=4546)