import { API } from "/static/api.js";
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
let M = null; let activeTab = "treemap";
const metricsById = () => {
const o = {};
for (const m of (M?.metrics || [])) o[m.id] = m;
return o;
};
export const metricLabel = (id) => (metricsById()[id]?.label) || id;
let treemapAxis, treemapMetric, treemapLevels;
let treemapBranchCap, treemapLeafCap, treemapLookahead, treemapMode;
let treemapBranchCapDefault, treemapLeafCapDefault, treemapLookaheadDefault, treemapModeDefault;
let treemapFilters = {}; let geoMap = null, geoMetric = null, geoOutline = false; let geoRegistry = null;
const geoLayerCache = {}; let geoColormap = "Blues", geoScale = "linear", geoLegendPos = "bl-h", geoLegendSize = 100, geoRes = "low";
let geoAnimType = "smooth", geoAnimDur = 600, geoEasing = "cubicInOut"; let geoProjection = "equalEarth"; export const CMAPS = [
{ name: "Blues", interp: "interpolateBlues" },
{ name: "Greens", interp: "interpolateGreens" },
{ name: "Oranges", interp: "interpolateOranges" },
{ name: "Reds", interp: "interpolateReds" },
{ name: "Purples", interp: "interpolatePurples" },
{ name: "Greys", interp: "interpolateGreys" },
{ name: "Viridis", interp: "interpolateViridis" },
{ name: "Magma", interp: "interpolateMagma" },
{ name: "Inferno", interp: "interpolateInferno" },
{ name: "Plasma", interp: "interpolatePlasma" },
{ name: "Cividis", interp: "interpolateCividis" },
{ name: "Turbo", interp: "interpolateTurbo" },
{ name: "Warm", interp: "interpolateWarm" },
{ name: "Cool", interp: "interpolateCool" },
{ name: "Cubehelix", interp: "interpolateCubehelixDefault" },
{ name: "YlGnBu", interp: "interpolateYlGnBu" },
{ name: "YlOrRd", interp: "interpolateYlOrRd" },
{ name: "GnBu", interp: "interpolateGnBu" },
{ name: "BuPu", interp: "interpolateBuPu" },
{ name: "PuBuGn", interp: "interpolatePuBuGn" },
{ name: "YlGn", interp: "interpolateYlGn" },
{ name: "RdBu (diverging)", interp: "interpolateRdBu", div: true },
{ name: "RdYlBu (diverging)", interp: "interpolateRdYlBu", div: true },
{ name: "RdYlGn (diverging)", interp: "interpolateRdYlGn", div: true },
{ name: "Spectral (diverging)", interp: "interpolateSpectral", div: true },
{ name: "BrBG (diverging)", interp: "interpolateBrBG", div: true },
{ name: "PiYG (diverging)", interp: "interpolatePiYG", div: true },
{ name: "PRGn (diverging)", interp: "interpolatePRGn", div: true },
{ name: "PuOr (diverging)", interp: "interpolatePuOr", div: true },
{ name: "RdGy (diverging)", interp: "interpolateRdGy", div: true },
];
const cmapSwatch = (interp) =>
`linear-gradient(to right, ${Array.from({ length: 11 }, (_, i) => d3[interp](i / 10)).join(",")})`;
let treemapOptions = {}; let lineMetric, lineAgg, lineWindow, lineScale = "linear", lineResolution = "d";
let entityWindow, entityMetric, entityScale = "linear", entityResolution = "d",
entityStyle = "line", entityShowMeta = false, entityMetric2 = "", entityTiling = "overlay";
export function setManifest(manifest) {
M = manifest;
const tv = M.views.treemap, sv = M.views.series, dv = M.views.detail;
if (tv) {
treemapAxis = tv.default_axis;
treemapMetric = tv.default_size_by;
treemapLevels = (tv.levels && tv.levels.default) || 2;
treemapBranchCap = tv.branch_cap ?? 12;
treemapLeafCap = tv.leaf_cap ?? 50;
treemapMode = (typeof tv.lookahead === "number") ? "windowed" : "full";
treemapLookahead = (typeof tv.lookahead === "number") ? tv.lookahead : 2;
treemapBranchCapDefault = treemapBranchCap;
treemapLeafCapDefault = treemapLeafCap;
treemapLookaheadDefault = treemapLookahead;
treemapModeDefault = treemapMode;
}
if (sv) {
const seriesMetrics = (sv.metrics && sv.metrics.length) ? sv.metrics : [];
lineMetric = (treemapMetric && seriesMetrics.includes(treemapMetric))
? treemapMetric
: (seriesMetrics[0] || treemapMetric);
lineAgg = (metricsById()[lineMetric]?.default_agg) || "sum";
lineWindow = sv.default_window;
lineResolution = sv.default_resolution ||
(sv.resolutions && sv.resolutions[0]) || "d";
}
const gv = M.views.geo;
if (gv) {
geoMap = gv.default_map || (gv.maps && gv.maps[0] && gv.maps[0].id);
geoMetric = (tv && tv.default_size_by) || (tv && tv.size_by && tv.size_by[0]);
geoOutline = gv.default_outline === true;
geoColormap = gv.default_colormap || "Blues";
geoScale = gv.default_scale || "linear";
geoLegendPos = "bl-h";
geoLegendSize = 100;
geoRes = gv.default_res || "low";
geoProjection = gv.default_projection || "equalEarth";
geoAnimType = "direct"; geoAnimDur = 600; geoEasing = "expOut";
readGeoUrl(); }
if (dv) {
entityWindow = (dv.windows && dv.windows[0] && dv.windows[0].id) || "1y";
entityMetric = (dv.series_metrics && dv.series_metrics[0]) || "";
entityResolution = dv.default_resolution ||
(dv.resolutions && dv.resolutions[0]) || "d";
entityShowMeta = !(dv.series_metrics && dv.series_metrics.length);
}
}
export const getManifest = () => M;
export const getGeoRegistry = () => geoRegistry;
export const geoGeographyById = (id) =>
(geoRegistry && geoRegistry.geographies.find(g => g.id === id)) || null;
export const getGeoLayer = (id) => geoLayerCache[id] || null;
async function loadGeoRegistry() {
if (geoRegistry) return geoRegistry;
try {
geoRegistry = await (await fetch("/static/vendor/geo/registry.json")).json();
} catch { geoRegistry = null; }
return geoRegistry;
}
async function loadGeoLayer(geo) {
if (!geo || geo.kind !== "static") return null;
if (geoLayerCache[geo.id]) return geoLayerCache[geo.id];
const merged = { metrics: [], values: {} };
const seen = new Set();
for (const fam of geo.families || []) {
let data;
try { data = await (await fetch(fam.file)).json(); } catch { continue; }
for (const m of data.metrics || []) {
if (seen.has(m.id)) continue;
seen.add(m.id); merged.metrics.push(m);
}
for (const [gid, vals] of Object.entries(data.values || {}))
merged.values[gid] = Object.assign(merged.values[gid] || {}, vals);
}
geoLayerCache[geo.id] = merged;
return merged;
}
export function getGeoMetricDefs() {
const geo = geoGeographyById(geoMap);
if (geo && geo.kind === "static") {
const layer = geoLayerCache[geo.id];
return layer ? layer.metrics : [];
}
const tv = M && M.views.treemap;
return ((tv && tv.size_by) || (geoMetric ? [geoMetric] : []))
.map(id => ({ id, label: metricLabel(id), unit: metricUnit(id) }));
}
export function geoMetricLabel(id) {
const layer = geoLayerCache[geoMap];
const m = layer && layer.metrics.find(x => x.id === id);
return m ? m.label : metricLabel(id);
}
export function geoMetricUnit(id) {
const layer = geoLayerCache[geoMap];
const m = layer && layer.metrics.find(x => x.id === id);
return m ? (m.unit || "number") : metricUnit(id);
}
export const getGeoMap = () => geoMap;
export const getGeoRes = () => geoRes;
export function setGeoRes(v) { if (!v || v === geoRes) return; geoRes = v; renderSidebar(); geoData(); }
export const getGeoMetric = () => geoMetric;
export const getGeoOutline = () => geoOutline;
export const getGeoColormap = () => geoColormap;
export const getGeoScale = () => geoScale;
export const getGeoLegendPos = () => geoLegendPos;
export const getGeoLegendSize = () => geoLegendSize;
const geoData = () => { syncGeoUrl(); document.dispatchEvent(new CustomEvent("geo-data-changed")); };
const geoStyle = () => { syncGeoUrl(); document.dispatchEvent(new CustomEvent("geo-style-changed")); };
const geoProj = () => { syncGeoUrl(); document.dispatchEvent(new CustomEvent("geo-proj-changed")); };
export const getGeoProjection = () => geoProjection;
export function setGeoProjection(v) { if (!v || v === geoProjection) return; geoProjection = v; renderSidebar(); geoProj(); }
export function syncGeoUrl() {
if (!M || !M.views.geo) return;
const u = new URL(location.href), p = u.searchParams;
p.set("g_map", geoMap);
p.set("g_metric", geoMetric);
p.set("g_cmap", geoColormap);
p.set("g_scale", geoScale);
p.set("g_legend", geoLegendPos);
p.set("g_lsize", String(geoLegendSize));
p.set("g_outline", geoOutline ? "1" : "0");
p.set("g_anim", geoAnimType);
p.set("g_dur", String(geoAnimDur));
p.set("g_ease", geoEasing);
p.set("g_res", geoRes);
p.set("g_proj", geoProjection);
for (const k of [...p.keys()]) if (k.startsWith("f_")) p.delete(k);
for (const [k, v] of Object.entries(getTreemapFilters()))
p.set("f_" + k, Array.isArray(v) ? v.join(",") : String(v));
const m = location.search.match(/[?&]focus=([^&]*)/);
p.delete("focus");
const qs = p.toString(), focus = m ? "focus=" + m[1] : "";
const full = [qs, focus].filter(Boolean).join("&");
history.replaceState({}, "", u.pathname + (full ? "?" + full : ""));
}
function readGeoUrl() {
if (!M || !M.views.geo) return;
const p = new URL(location.href).searchParams, tv = M.views.treemap;
if (p.get("g_map")) geoMap = p.get("g_map");
if (p.get("g_metric")) geoMetric = p.get("g_metric");
if (p.get("g_cmap")) geoColormap = p.get("g_cmap");
if (p.get("g_scale")) geoScale = p.get("g_scale");
if (p.get("g_legend")) geoLegendPos = p.get("g_legend");
if (p.get("g_lsize")) geoLegendSize = Math.max(50, Math.min(300, parseInt(p.get("g_lsize"), 10) || 100));
if (p.get("g_outline") != null) geoOutline = p.get("g_outline") === "1";
if (p.get("g_anim")) geoAnimType = p.get("g_anim");
if (p.get("g_dur")) geoAnimDur = Math.max(0, Math.min(1200, parseInt(p.get("g_dur"), 10) || 600));
if (p.get("g_ease")) geoEasing = p.get("g_ease");
if (p.get("g_res")) geoRes = p.get("g_res");
if (p.get("g_proj")) geoProjection = p.get("g_proj");
for (const f of (tv && tv.filters) || []) {
const raw = p.get("f_" + f.id);
if (raw == null) continue;
treemapFilters[f.id] = f.control === "multiselect" ? raw.split(",") : raw;
}
}
document.addEventListener("treemap-filters-changed", () => { if (activeTab === "map") syncGeoUrl(); });
document.addEventListener("view-changed", (e) => { if (e.detail === "map") syncGeoUrl(); });
export function setGeoMap(v) {
if (!v || v === geoMap) return;
const geo = geoGeographyById(v);
if (geo && !geo.available) return; geoMap = v;
if (geo && geo.kind === "static") {
renderSidebar();
loadGeoLayer(geo).then(layer => {
if (!layer.metrics.find(m => m.id === geoMetric))
geoMetric = layer.metrics[0] && layer.metrics[0].id;
renderSidebar(); geoData();
}).catch(() => { renderSidebar(); geoData(); });
return;
}
const tv = M && M.views.treemap;
const valid = (tv && tv.size_by) || [];
if (valid.length && !valid.includes(geoMetric))
geoMetric = (tv && tv.default_size_by) || valid[0];
renderSidebar(); geoData();
}
export function setGeoMetric(v) { if (!v || v === geoMetric) return; geoMetric = v; renderSidebar(); geoStyle(); }
export function setGeoOutline(on) { on = !!on; if (on === geoOutline) return; geoOutline = on; renderSidebar(); geoStyle(); }
export function setGeoColormap(v) { if (!v || v === geoColormap) return; geoColormap = v; renderSidebar(); geoStyle(); }
export function setGeoScale(v) { if (!v || v === geoScale) return; geoScale = v; renderSidebar(); geoStyle(); }
export function setGeoLegendPos(v) { if (!v || v === geoLegendPos) return; geoLegendPos = v; renderSidebar(); geoStyle(); }
export function setGeoLegendSize(v) {
const n = Math.max(50, Math.min(300, parseInt(v, 10) || 100));
if (n === geoLegendSize) return;
geoLegendSize = n; geoStyle(); }
export const getGeoAnimType = () => geoAnimType;
export const getGeoAnimDuration = () => geoAnimDur;
export const getGeoEasing = () => geoEasing;
export function setGeoAnimType(v) { if (!v || v === geoAnimType) return; geoAnimType = v; renderSidebar(); syncGeoUrl(); }
export function setGeoEasing(v) { if (!v || v === geoEasing) return; geoEasing = v; renderSidebar(); syncGeoUrl(); }
export function setGeoAnimDur(v) {
const n = Math.max(0, Math.min(1200, parseInt(v, 10) || 0));
if (n === geoAnimDur) return;
geoAnimDur = n; syncGeoUrl(); }
function legendPosSelectHTML(cur) {
const grp = (lab, opts) => `<optgroup label="${lab}">` +
opts.map(([v, t]) => `<option value="${v}"${v === cur ? " selected" : ""}>${esc(t)}</option>`).join("") +
`</optgroup>`;
return `<select id="geo-legendpos" class="filter-select" style="width:100%;box-sizing:border-box;margin-bottom:16px">` +
grp("Spanning", [["span-bottom", "Bottom (full)"], ["span-top", "Top (full)"], ["span-left", "Left (full)"], ["span-right", "Right (full)"]]) +
grp("Centered", [["bottom", "Bottom center"], ["top", "Top center"], ["left", "Left center"], ["right", "Right center"]]) +
grp("Corners", [
["bl-h", "Bottom-left, horizontal"], ["bl-v", "Bottom-left, vertical"],
["br-h", "Bottom-right, horizontal"], ["br-v", "Bottom-right, vertical"],
["tl-h", "Top-left, horizontal"], ["tl-v", "Top-left, vertical"],
["tr-h", "Top-right, horizontal"], ["tr-v", "Top-right, vertical"],
]) + `</select>`;
}
function geographySelectHTML(cur) {
const geos = (geoRegistry && geoRegistry.geographies) || null;
if (!geos) { const cells = ((M.views.geo && M.views.geo.maps) || []).map(m => [m.id, m.label || m.id]);
return segHTML("geo-map", cells, cur);
}
const order = ["National", "Sub-county", "High-resolution"];
const byGroup = {};
for (const g of geos) (byGroup[g.group] = byGroup[g.group] || []).push(g);
const groups = order.filter(o => byGroup[o]).concat(Object.keys(byGroup).filter(k => !order.includes(k)));
const opt = (g) => {
const note = g.available ? "" : ` — ${g.note || "unavailable"}`;
return `<option value="${esc(g.id)}"${g.id === cur ? " selected" : ""}` +
`${g.available ? "" : " disabled"}>${esc(g.label)}${esc(note)}</option>`;
};
return `<select id="geo-map-sel" class="filter-select" style="width:100%;box-sizing:border-box;margin-bottom:16px">` +
groups.map(gn => `<optgroup label="${esc(gn)}">${byGroup[gn].map(opt).join("")}</optgroup>`).join("") +
`</select>`;
}
function cmapDropdownHTML(cur) {
const c = CMAPS.find(x => x.name === cur) || CMAPS[0];
const row = (x) =>
`<div class="cmap-opt" data-v="${esc(x.name)}"><span class="cmap-swatch" style="background:${cmapSwatch(x.interp)}"></span><span>${esc(x.name)}</span></div>`;
return `<div class="cmap-dd" id="cmap-dd">` +
`<button type="button" class="cmap-cur" id="cmap-toggle"><span class="cmap-swatch" style="background:${cmapSwatch(c.interp)}"></span><span>${esc(c.name)}</span><span class="cmap-caret">▾</span></button>` +
`<div class="cmap-list" id="cmap-list" style="display:none">${CMAPS.map(row).join("")}</div></div>`;
}
function renderMapSidebar(root) {
const gv = M.views.geo, tv = M.views.treemap;
if (!gv) { root.innerHTML = ""; return; }
const metricCells = getGeoMetricDefs().map(m => [m.id, m.label]);
const geo = geoGeographyById(geoMap);
const isUS = geo ? !!geo.us : (geoMap === "states" || geoMap === "counties");
const isBuiltin = geo ? geo.kind === "builtin" : true;
const showRes = isBuiltin;
const isUSmap = isUS;
const projCells = [["naturalEarth", "Flat"], ["orthographic", "Globe"]];
root.innerHTML =
label("Geography") + geographySelectHTML(geoMap) +
(showRes ? label("Resolution") + segHTML("geo-res", [["low", "Low"], ["med", "Med"], ["high", "High"]], geoRes) : "") +
(isUSmap ? "" : label("Projection") + segHTML("geo-proj", projCells, geoProjection)) +
label("Color by") + segHTML("geo-metric", metricCells, geoMetric) +
label("Colormap") + cmapDropdownHTML(geoColormap) +
label("Color scale") + segHTML("geo-scale", [["linear", "Linear"], ["log", "Log"]], geoScale) +
label("Legend position") + legendPosSelectHTML(geoLegendPos) +
label("Colorbar size") + sliderHTML("legendsize", geoLegendSize, 50, 300) +
label("State outlines") + segHTML("geo-outline", [["off", "Off"], ["on", "On"]], geoOutline ? "on" : "off") +
label("Zoom animation") + segHTML("geo-anim", [["smooth", "Smooth"], ["direct", "Direct"]], geoAnimType) +
label("Zoom easing") + segHTML("geo-ease", [["cubicInOut", "Cubic"], ["cubicOut", "Out"], ["quadOut", "Quad"], ["expOut", "Exp"], ["linear", "Linear"]], geoEasing) +
label("Zoom duration (ms)") + sliderHTML("zoomdur", geoAnimDur, 0, 1200) +
filtersHTML((tv && tv.filters) || []);
const geoSel = root.querySelector("#geo-map-sel");
if (geoSel) geoSel.addEventListener("change", (e) => setGeoMap(e.target.value));
else wireSeg(root, "geo-map", setGeoMap); wireSeg(root, "geo-res", setGeoRes);
wireSeg(root, "geo-proj", setGeoProjection);
wireSeg(root, "geo-metric", setGeoMetric);
wireSeg(root, "geo-scale", setGeoScale);
wireSeg(root, "geo-outline", v => setGeoOutline(v === "on"));
const lp = root.querySelector("#geo-legendpos");
if (lp) lp.addEventListener("change", (e) => setGeoLegendPos(e.target.value));
wireSlider(root, "legendsize", setGeoLegendSize);
wireSeg(root, "geo-anim", setGeoAnimType);
wireSeg(root, "geo-ease", setGeoEasing);
wireSlider(root, "zoomdur", setGeoAnimDur);
wireFilters(root, (tv && tv.filters) || []);
const toggle = root.querySelector("#cmap-toggle"), listEl = root.querySelector("#cmap-list");
if (toggle && listEl) {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const open = listEl.style.display !== "none";
listEl.style.display = open ? "none" : "block";
if (!open) setTimeout(() => document.addEventListener("click", function h() {
listEl.style.display = "none"; document.removeEventListener("click", h);
}), 0);
});
for (const opt of root.querySelectorAll(".cmap-opt"))
opt.addEventListener("click", () => setGeoColormap(opt.dataset.v));
}
}
export const getTreemapAxis = () => treemapAxis;
export const getTreemapMetric = () => treemapMetric;
export const getTreemapLevels = () => treemapLevels;
export const getTreemapLookahead = () => (treemapMode === "full" ? null : treemapLookahead);
export const getTreemapBranchCap = () => treemapBranchCap;
export const getTreemapLeafCap = () => treemapLeafCap;
export const getTreemapMode = () => treemapMode;
export const getTreemapFilters = () => {
const out = {};
for (const [k, v] of Object.entries(treemapFilters))
if (v != null && v !== "" && !(Array.isArray(v) && v.length === 0)) out[k] = v;
return out;
};
export function getTreemapTitle() {
const plural = M?.entity_noun_plural;
const base = plural ? `All ${plural}` : (M?.title || "All");
const parts = Object.entries(getTreemapFilters()).map(([k, v]) =>
Array.isArray(v) ? v.join("/") : String(v));
return parts.length ? `${base} · ${parts.join(", ")}` : base;
}
export const getLineMetric = () => lineMetric;
export const getLineAgg = () => lineAgg;
export const getLineWindow = () => lineWindow;
export const getLineScale = () => lineScale;
export const getLineResolution = () => lineResolution;
export const getEntityWindow = () => entityWindow;
export const getEntityMetric = () => entityMetric;
export const getEntityScale = () => entityScale;
export const getEntityResolution = () => entityResolution;
export const getEntityStyle = () => entityStyle;
export const getEntityShowMeta = () => entityShowMeta;
export const getEntityMetric2 = () => entityMetric2;
export const getEntityTiling = () => entityTiling;
export const getSelection = () => null; export const getSizeByMetrics = () => (M?.views?.treemap?.size_by) || [];
export const metricUnit = (id) => (metricsById()[id]?.unit) || "number";
export function syncTreemapPickerUrl() {
const u = new URL(location.href), p = u.searchParams;
const tv = M.views.treemap;
const set = (k, v, def) => (v === def ? p.delete(k) : p.set(k, v));
set("axis", treemapAxis, tv.default_axis);
set("metric", treemapMetric, tv.default_size_by);
set("levels", String(treemapLevels), String((tv.levels && tv.levels.default) || 2));
const m = location.search.match(/[?&]focus=([^&]*)/);
p.delete("focus");
const qs = p.toString();
const focus = m ? "focus=" + m[1] : "";
const full = [qs, focus].filter(Boolean).join("&");
history.replaceState({}, "", u.pathname + (full ? "?" + full : ""));
}
export function reloadTreemapPickerFromUrl() {
const p = new URL(location.href).searchParams;
const tv = M.views.treemap;
treemapAxis = p.get("axis") || tv.default_axis;
treemapMetric = p.get("metric") || tv.default_size_by;
const lv = parseInt(p.get("levels"), 10);
treemapLevels = Number.isFinite(lv) ? lv : ((tv.levels && tv.levels.default) || 2);
if (activeTab === "treemap") renderSidebar();
}
export async function initSidebar() {
if (M.views.geo) {
await loadGeoRegistry();
const geo = geoGeographyById(geoMap);
if (geo && geo.kind === "static") {
const layer = await loadGeoLayer(geo);
if (layer && !layer.metrics.find(m => m.id === geoMetric))
geoMetric = layer.metrics[0] && layer.metrics[0].id;
}
}
const tv = M.views.treemap;
if (tv) {
for (const f of tv.filters || []) {
if (f.options_provider) {
try { treemapOptions[f.id] = await API.filterOptions(f.id); } catch { treemapOptions[f.id] = []; }
} else if (f.options) {
treemapOptions[f.id] = f.options;
}
}
for (const f of tv.filters || []) {
if (f.default == null) continue;
if (f.control === "multiselect") {
if (treemapFilters[f.id] === undefined)
treemapFilters[f.id] = (Array.isArray(f.default) ? f.default : [f.default]).map(String);
} else if (f.control === "range") {
if (Array.isArray(f.default)) {
if (treemapFilters[f.id + "_min"] === undefined) treemapFilters[f.id + "_min"] = f.default[0];
if (treemapFilters[f.id + "_max"] === undefined) treemapFilters[f.id + "_max"] = f.default[1];
}
} else if (treemapFilters[f.id] === undefined) {
treemapFilters[f.id] = String(f.default);
}
}
}
renderSidebar();
}
export function setActiveTab(tab) {
activeTab = tab;
if (M) renderSidebar();
}
function renderSidebar() {
const root = document.querySelector("#sidebar-content");
if (!root || !M) return;
if (activeTab === "map") return renderMapSidebar(root);
if (activeTab === "treemap") return renderTreemapSidebar(root);
if (activeTab === "timeseries") return renderLineSidebar(root);
if (activeTab === "scatter") return renderScatterSidebar(root);
if (activeTab === "entity") return renderEntitySidebar(root);
root.innerHTML = "";
}
function segHTML(group, cells, current) {
return `<div class="mode-tabs" data-group="${group}">` +
cells.map(([v, lab, disabled]) =>
`<button data-v="${esc(v)}"${disabled ? " disabled" : ""} class="${v === current ? "active" : ""}">${esc(lab)}</button>`).join("") +
`</div>`;
}
function wireSeg(root, group, onPick) {
for (const btn of root.querySelectorAll(`[data-group="${group}"] button`))
if (!btn.disabled) btn.addEventListener("click", () => onPick(btn.dataset.v));
}
function label(text, cls = "") { return `<label class="picker-label ${cls}">${esc(text)}</label>`; }
function sliderHTML(id, value, min, max) {
return `<div class="slider-row">` +
`<input type="range" id="tm-${id}" min="${min}" max="${max}" step="1" value="${value}">` +
`<input type="number" class="slider-val" id="tm-${id}-num" min="${min}" max="${max}" step="1" value="${value}"></div>`;
}
function wireSlider(root, id, setter) {
const range = root.querySelector(`#tm-${id}`);
const num = root.querySelector(`#tm-${id}-num`);
if (!range) return;
const lo = +range.min, hi = +range.max;
const push = (v) => { range.value = v; if (num) num.value = v; setter(v); };
range.addEventListener("input", () => push(range.value));
range.addEventListener("change", () => push(range.value));
if (num) {
num.addEventListener("input", () => { range.value = num.value; setter(num.value); });
num.addEventListener("change", () =>
push(Math.max(lo, Math.min(hi, parseInt(num.value, 10) || lo))));
}
}
export function setTreemapAxis(v) {
if (!v || v === treemapAxis) return;
treemapAxis = v;
const ax = (M?.views?.treemap?.axes || []).find(a => a.id === v);
const valid = (ax && ax.size_by) || (M?.views?.treemap?.size_by) || [];
if (valid.length && !valid.includes(treemapMetric))
treemapMetric = (ax && ax.default_size_by) || valid[0];
else if (ax && ax.default_size_by) treemapMetric = ax.default_size_by;
renderSidebar();
syncTreemapPickerUrl();
document.dispatchEvent(new CustomEvent("treemap-axis-changed", { detail: v }));
}
export function setTreemapMetric(v) {
if (!v || v === treemapMetric) return;
treemapMetric = v;
const sv = M.views.series;
if (sv && (sv.metrics || []).includes(v)) {
lineMetric = v;
lineAgg = (metricsById()[v]?.default_agg) || "sum";
}
renderSidebar();
syncTreemapPickerUrl();
document.dispatchEvent(new CustomEvent("treemap-metric-changed", { detail: v }));
}
export function setTreemapLevels(n) {
n = parseInt(n, 10);
if (!Number.isFinite(n) || n === treemapLevels) return;
const lv = (M?.views?.treemap?.levels) || { min: 1, max: 4 };
if (n < lv.min || n > lv.max) return; treemapLevels = n;
renderSidebar();
syncTreemapPickerUrl();
document.dispatchEvent(new CustomEvent("treemap-levels-changed", { detail: n }));
}
const _clampInt = (v, lo, hi) => {
const n = parseInt(v, 10);
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : null;
};
export function setTreemapBranchCap(v) {
const n = _clampInt(v, 1, 100); if (n === null || n === treemapBranchCap) return;
treemapBranchCap = n;
document.dispatchEvent(new CustomEvent("treemap-bounds-changed"));
}
export function setTreemapLeafCap(v) {
const n = _clampInt(v, 1, 100);
if (n === null || n === treemapLeafCap) return;
treemapLeafCap = n;
document.dispatchEvent(new CustomEvent("treemap-bounds-changed"));
}
export function setTreemapLookahead(v) {
const n = _clampInt(v, 0, 12);
if (n === null || n === treemapLookahead) return;
treemapLookahead = n;
document.dispatchEvent(new CustomEvent("treemap-bounds-changed"));
}
export function setTreemapMode(v) {
if ((v !== "windowed" && v !== "full") || v === treemapMode) return;
treemapMode = v;
renderSidebar();
document.dispatchEvent(new CustomEvent("treemap-bounds-changed"));
}
export function resetTreemapBounds() {
treemapMode = treemapModeDefault;
treemapBranchCap = treemapBranchCapDefault;
treemapLeafCap = treemapLeafCapDefault;
treemapLookahead = treemapLookaheadDefault;
renderSidebar();
document.dispatchEvent(new CustomEvent("treemap-bounds-changed"));
}
function cycleIds(ids, current, dir, set) {
if (!ids.length) return;
const i = ids.indexOf(current);
set(ids[(i + dir + ids.length) % ids.length]);
}
export const cycleTreemapAxis = (dir) =>
cycleIds((M?.views?.treemap?.axes || []).map(a => a.id), treemapAxis, dir, setTreemapAxis);
export const cycleTreemapMetric = (dir) =>
cycleIds(M?.views?.treemap?.size_by || [], treemapMetric, dir, setTreemapMetric);
export const getActiveTab = () => activeTab;
function renderTreemapSidebar(root) {
const tv = M.views.treemap;
const axisCells = tv.axes.map(a => [a.id, a.label]);
const curAxis = tv.axes.find(a => a.id === treemapAxis);
const validMetrics = (curAxis && curAxis.size_by) || tv.size_by;
const sizeCells = tv.size_by.map(id => [id, metricLabel(id), !validMetrics.includes(id)]);
const lv = tv.levels || { min: 1, max: 4, default: 2 };
const levelCells = [];
for (let n = lv.min; n <= lv.max; n++) levelCells.push([String(n), String(n)]);
const windowed = treemapMode === "windowed";
const bounding =
label("Window mode") +
segHTML("tmmode", [["windowed", "Windowed"], ["full", "Full"]], treemapMode) +
(windowed
? label('Branch cap ("+N others")') + sliderHTML("branchcap", treemapBranchCap, 1, 100)
+ label("Lookahead") + sliderHTML("lookahead", treemapLookahead, 0, 12)
: label('Leaf cap ("+N others")') + sliderHTML("leafcap", treemapLeafCap, 1, 100)) +
`<button type="button" class="reset-bounds" id="tm-reset">Reset to defaults</button>`;
root.innerHTML =
label("Bucket by") + segHTML("axis", axisCells, treemapAxis) +
label("Size by") + segHTML("metric", sizeCells, treemapMetric) +
label("Levels") + segHTML("levels", levelCells, String(treemapLevels)) +
filtersHTML(tv.filters || []) +
`<div class="bounding-controls">${bounding}</div>`;
wireSeg(root, "axis", setTreemapAxis);
wireSeg(root, "metric", setTreemapMetric);
wireSeg(root, "levels", setTreemapLevels);
wireFilters(root, tv.filters || []);
wireSeg(root, "tmmode", setTreemapMode);
wireSlider(root, "branchcap", setTreemapBranchCap);
wireSlider(root, "leafcap", setTreemapLeafCap);
wireSlider(root, "lookahead", setTreemapLookahead);
const reset = root.querySelector("#tm-reset");
if (reset) reset.addEventListener("click", resetTreemapBounds);
}
function filtersHTML(facets) {
return facets.map(f => {
if (f.control === "range") {
return label(`Filter — ${f.label}`, "filter") +
`<div class="age-range">
<input id="flt-${f.id}-min" type="number" placeholder="from" value="${treemapFilters[f.id + "_min"] ?? ""}">
<span class="age-sep">–</span>
<input id="flt-${f.id}-max" type="number" placeholder="to" value="${treemapFilters[f.id + "_max"] ?? ""}">
</div>`;
}
const opts = treemapOptions[f.id] || f.options || [];
if (f.control === "multiselect") {
const sel = new Set((treemapFilters[f.id] || []).map(String));
return label(`Filter — ${f.label}`, "filter") +
`<div class="mode-tabs" data-group="flt-${f.id}">` +
opts.map(o => `<button data-v="${esc(o)}" class="${sel.has(String(o)) ? "active" : ""}">${esc(o)}</button>`).join("") +
`</div>`;
}
const allNum = opts.length && opts.every(o => o !== "" && !isNaN(Number(o)));
const sorted = allNum ? [...opts].sort((a, b) => Number(b) - Number(a)) : opts;
const cur = treemapFilters[f.id] != null ? String(treemapFilters[f.id]) : "";
return label(`Filter — ${f.label}`, "filter") +
`<select id="flt-${f.id}" class="filter-select">` +
`<option value="">Any</option>` +
sorted.map(o => `<option value="${esc(o)}"${String(o) === cur ? " selected" : ""}>${esc(o)}</option>`).join("") +
`</select>`;
}).join("");
}
function wireFilters(root, facets) {
const changed = () => document.dispatchEvent(new CustomEvent("treemap-filters-changed"));
for (const f of facets) {
if (f.control === "range") {
for (const which of ["min", "max"]) {
const el = root.querySelector(`#flt-${f.id}-${which}`);
if (!el) continue;
const apply = () => {
const v = el.value.trim();
treemapFilters[`${f.id}_${which}`] = v === "" ? null : parseInt(v, 10);
syncTreemapPickerUrl(); changed();
};
el.addEventListener("input", apply);
el.addEventListener("change", apply);
}
} else if (f.control === "multiselect") {
for (const btn of root.querySelectorAll(`[data-group="flt-${f.id}"] button`))
btn.addEventListener("click", () => {
const set = new Set(treemapFilters[f.id] || []);
set.has(btn.dataset.v) ? set.delete(btn.dataset.v) : set.add(btn.dataset.v);
treemapFilters[f.id] = [...set];
renderSidebar(); changed();
});
} else {
const el = root.querySelector(`#flt-${f.id}`);
if (!el) continue;
el.addEventListener("change", () => {
treemapFilters[f.id] = el.value || null;
changed();
});
}
}
}
function renderLineSidebar(root) {
const sv = M.views.series, tv = M.views.treemap;
const metricIds = (sv.metrics && sv.metrics.length) ? sv.metrics : (tv ? tv.size_by : []);
const metricCells = metricIds.map(id => [id, metricLabel(id)]);
const winCells = sv.windows.map(w => [w.id, w.id]);
const resCells = sv.resolutions.map(r => [r, r.toUpperCase()]);
const aggCells = sv.aggs.map(a => [a, a]);
root.innerHTML =
(tv ? label("Bucket by") + segHTML("axis", tv.axes.map(a => [a.id, a.label]), treemapAxis) : "") +
label("Metric") + segHTML("line-metric", metricCells, lineMetric) +
label("Aggregate") + segHTML("line-agg", aggCells, lineAgg) +
label("Window") + segHTML("line-window", winCells, lineWindow) +
label("Resolution") + segHTML("line-res", resCells, lineResolution) +
(sv.log_scale ? label("Y scale") + segHTML("line-scale", [["linear", "linear"], ["log", "log"]], lineScale) : "") +
(tv ? filtersHTML(tv.filters || []) : "");
if (tv) wireSeg(root, "axis", v => {
if (v === treemapAxis) return;
treemapAxis = v; renderSidebar(); syncTreemapPickerUrl();
document.dispatchEvent(new CustomEvent("treemap-axis-changed", { detail: v }));
});
const setMetric = (v) => {
lineMetric = v; lineAgg = (metricsById()[v]?.default_agg) || "median";
renderSidebar();
document.dispatchEvent(new CustomEvent("line-metric-changed", { detail: v }));
};
wireSeg(root, "line-metric", setMetric);
wireSeg(root, "line-agg", v => { if (v === lineAgg) return; lineAgg = v; renderSidebar(); document.dispatchEvent(new CustomEvent("line-agg-changed", { detail: v })); });
wireSeg(root, "line-window", v => { if (v === lineWindow) return; lineWindow = v; renderSidebar(); document.dispatchEvent(new CustomEvent("line-window-changed", { detail: v })); });
wireSeg(root, "line-res", v => { if (v === lineResolution) return; lineResolution = v; renderSidebar(); document.dispatchEvent(new CustomEvent("line-resolution-changed", { detail: v })); });
wireSeg(root, "line-scale", v => { if (v === lineScale) return; lineScale = v; renderSidebar(); document.dispatchEvent(new CustomEvent("line-scale-changed", { detail: v })); });
if (tv) wireFilters(root, tv.filters || []);
}
let scatterX, scatterY;
export const getScatterX = () => scatterX;
export const getScatterY = () => scatterY;
function renderScatterSidebar(root) {
const sc = M.views.scatter;
scatterX = scatterX || sc.default_x;
scatterY = scatterY || sc.default_y;
const opts = (cur) => sc.metrics.map(id => `<option value="${esc(id)}" ${id === cur ? "selected" : ""}>${esc(metricLabel(id))}</option>`).join("");
root.innerHTML =
label("X axis") + `<select id="sc-x">${opts(scatterX)}</select>` +
label("Y axis") + `<select id="sc-y">${opts(scatterY)}</select>`;
root.querySelector("#sc-x").addEventListener("change", e => { scatterX = e.target.value; document.dispatchEvent(new CustomEvent("scatter-changed")); });
root.querySelector("#sc-y").addEventListener("change", e => { scatterY = e.target.value; document.dispatchEvent(new CustomEvent("scatter-changed")); });
}
function renderEntitySidebar(root) {
const dv = M.views.detail;
if (!dv) { root.innerHTML = ""; return; }
const winCells = dv.windows.map(w => [w.id, w.id]);
const resCells = dv.resolutions.map(r => [r, r.toUpperCase()]);
const metricOpts = (dv.series_metrics || []).map(id => `<option value="${esc(id)}" ${id === entityMetric ? "selected" : ""}>${esc(metricLabel(id))}</option>`).join("");
const styleCells = (dv.chart_styles || ["line", "area"]).map(s => [s, s]);
root.innerHTML =
label("Window") + segHTML("ent-window", winCells, entityWindow) +
label("Resolution") + segHTML("ent-res", resCells, entityResolution) +
label("Metric") + `<select id="ent-metric">${metricOpts}</select>` +
label("Graph") + segHTML("ent-style", styleCells, entityStyle) +
label("Y scale") + segHTML("ent-scale", [["linear", "linear"], ["log", "log"]], entityScale) +
label("Metadata") + segHTML("ent-meta", [["show", "show"], ["hide", "hide"]], entityShowMeta ? "show" : "hide");
wireSeg(root, "ent-window", v => { if (v === entityWindow) return; entityWindow = v; renderSidebar(); document.dispatchEvent(new CustomEvent("entity-window-changed", { detail: v })); });
wireSeg(root, "ent-res", v => { if (v === entityResolution) return; entityResolution = v; renderSidebar(); document.dispatchEvent(new CustomEvent("entity-resolution-changed", { detail: v })); });
const me = root.querySelector("#ent-metric");
if (me) me.addEventListener("change", () => { entityMetric = me.value; document.dispatchEvent(new CustomEvent("entity-metric-changed", { detail: entityMetric })); });
wireSeg(root, "ent-style", v => { if (v === entityStyle) return; entityStyle = v; renderSidebar(); document.dispatchEvent(new CustomEvent("entity-style-changed", { detail: v })); });
wireSeg(root, "ent-scale", v => { if (v === entityScale) return; entityScale = v; renderSidebar(); document.dispatchEvent(new CustomEvent("entity-scale-changed", { detail: v })); });
wireSeg(root, "ent-meta", v => { const show = v === "show"; if (show === entityShowMeta) return; entityShowMeta = show; renderSidebar(); document.dispatchEvent(new CustomEvent("entity-meta-toggled", { detail: show })); });
}