import plotly.graph_objects as go
import os, json, yaml, math, pandas as pd
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
# ---------------- Config & Data ----------------
CFG_PATH = os.getenv("MAP_CONFIG", "map_config.yaml")
with open(CFG_PATH, "r", encoding="utf-8") as f:
CFG = yaml.safe_load(f)
F = CFG["fields"]
# Prefer filtered land data if present
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)
# Resolve aliases commonly used
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 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)
# Normalizations
if 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 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["__list_list__"] = [[] for _ in range(len(df))]
def _opts(series):
if 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})
# ---------------- App UI ----------------
app = Dash(__name__, suppress_callback_exceptions=True)
server = app.server
header = dmc.AppShellHeader(
dmc.Group(
[
dmc.Text("US Professionals Map (SIMPLE + Details Panel)", 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",
)
# 12-column-like layout using widths: map 10/12 ≈ 83.333%, details 2/12 ≈ 16.667%
content_desktop = dmc.Box(
style={"display": "grid", "gridTemplateColumns": "5fr 1fr", "gap": "16px"},
visibleFrom="lg",
children=[
dmc.Paper(
withBorder=True, shadow="xs", radius="md",
p=10, # bỏ padding để canvas không bị “tràn”
style={"height": "75vh", "overflow": "hidden"}, # quan trọng: clip theo bo góc
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={"minHeight": "75vh", "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"),
],
),
],
)
# Mobile layout (hidden from lg): stacked
content_mobile = dmc.Stack(
[
dmc.Paper( # đổi từ Box -> Paper cho đồng bộ + clip
withBorder=True, shadow="xs", radius="md",
p=0,
style={"height": "60vh", "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",
)
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)
]
)
)
# ---------------- Helpers ----------------
def apply_filters(state, role, lists, name, approved_only):
q = df.copy()
if state and F.get("state") in q.columns: q = q[q[F["state"]].isin(state)]
if role and F.get("role") in q.columns: q = q[q[F["role"]].isin(role)]
if lists: q = q[q["__list_list__"].apply(lambda L: any(x in L for x in lists))]
if approved_only and F.get("is_approved") in q.columns: q = q[q[F["is_approved"]] == True]
if 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)
# ---------------- Callbacks ----------------
@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, height=720, zoom=z)
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"], height=720, zoom=z)
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))
meta = f"Points: {len(q):,} / {len(df):,} • Style: {style} • Cluster: {use_cluster} • Approved: {approved_only}"
return fig, meta
from dash import no_update
@app.callback(
Output("map_m", "figure"),
Input("map", "figure"),
prevent_initial_call=False
)
def sync_mobile_figure(fig):
# Khi desktop figure có dữ liệu, mobile sẽ nhận giống hệt.
# Nếu fig vẫn None (lần render rất sớm), trả về Figure rỗng thay vì None
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
# ---- Export ----
@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)