Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions claude_plans/20260515-region-histogram.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Region polygon → per-season KDE histogram (viewer)

Status: done (2026-05-15) — implemented; custom right-side legend with
hover focus-highlight (uPlot's live legend reflowed and was dropped).

## Goal
Let the user draw a polygon on the map; show a seaborn-`kdeplot`-style panel of
the selected variable's distribution — one unit-area Gaussian-KDE curve per
enabled season plus a bold "All" curve. Polygon persists across variable
changes (only the curves recompute); honors the season on/off checkboxes.

## Decisions
- Draw tool: custom lightweight (no plugin).
- Chart: `uplot` (new dep) + its CSS.
- Curves: Gaussian KDE, Silverman bandwidth, normalized to unit area.
- Season filter: chart respects the existing season checkboxes.

## Pieces
1. **map.ts** — polygon draw + point-in-polygon
- State: drawing flag, vertex latlngs, provisional polyline, final polygon.
- `startPolygonDraw()`, `clearPolygon()`, `hasPolygon()`, `onPolygonChange(cb)`.
- Click adds vertex; dblclick / Enter finishes; Esc cancels. Suppress
doubleClickZoom + hover tooltip while drawing.
- `tracesInPolygon(data)` → indices: project vertices + each trace via
`map.options.crs.project` (zoom-independent, pole-correct), ray-cast.
- Clear polygon on hemisphere rebuild / destroyMap.
2. **histogram.ts** — KDE + uPlot
- `kde(values, gridX)` Gaussian, bw = 1.06·σ·n^(-1/5) (skip n<2 / σ=0),
trapezoidal renorm to area 1.
- Categorical season→color map (stable, chroma brewer), "All" = bold light.
- uPlot line chart in a floating panel; title = variable label/unit.
3. **main.ts** — wiring
- Sidebar "Draw region" / "Clear region" buttons + `#region-panel`.
- onPolygonChange → cache in-polygon indices → updateRegionHistogram().
- updateRegionHistogram(): group cached∩qcPass∩!NaN indices by
frameCollection, keep enabled seasons; x-range = 2–98 pct of union;
KDE per season + All; render; show panel.
- Recompute on variable change (after ensureVariable) and season toggle;
polygon/index cache untouched. Clear on dataset switch.

## Notes
- Panel: bottom-left, above Leaflet scale control, dark-theme styled.
- Seasons with <2 in-polygon points: no own curve but counted in "All".
114 changes: 114 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,107 @@
.leaflet-container {
background: #1a1a2e;
}

.region-buttons {
display: flex;
gap: 6px;
}

.region-buttons button {
flex: 1;
background: #0f3460;
color: #e6e6e6;
border: 1px solid #16527a;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
}

.region-buttons button:hover {
background: #16527a;
}

.region-buttons button:disabled {
opacity: 0.45;
cursor: default;
}

#region-panel {
position: absolute;
bottom: 30px;
left: 10px;
z-index: 999;
background: rgba(22, 33, 62, 0.94);
border: 1px solid #0f3460;
border-radius: 4px;
padding: 8px;
}

#region-panel[hidden] {
display: none;
}

#region-body {
display: flex;
align-items: flex-start;
}

#region-chart {
width: 460px;
}

#region-legend {
width: 150px;
margin-left: 8px;
padding-top: 22px;
font-size: 11px;
color: #ccc;
}

#region-legend .leg-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
line-height: 1.2;
padding: 1px 4px;
border-radius: 3px;
}

#region-legend .leg-swatch {
flex: 0 0 auto;
width: 16px;
height: 0;
border-top-style: solid;
}

#region-legend .leg-row.leg-all {
font-weight: 700;
color: #fff;
}

#region-legend .leg-row.leg-hi {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}

#region-chart .region-empty {
color: #aaa;
font-size: 12px;
padding: 24px 12px;
text-align: center;
}

.u-title {
font-size: 12px;
color: #ccc;
}

.u-legend {
font-size: 11px;
color: #ccc;
}
</style>
</head>
<body>
Expand All @@ -265,6 +366,13 @@ <h2>Variable</h2>
<option value="bed_twtt">Bed TWTT [µs]</option>
</select>
</div>
<div class="sidebar-section">
<h2>Region</h2>
<div class="region-buttons">
<button id="draw-region-btn">Draw region</button>
<button id="clear-region-btn" disabled>Clear region</button>
</div>
</div>
<details class="sidebar-section" id="seasons-section" hidden>
<summary>Seasons</summary>
<div id="season-list"></div>
Expand All @@ -284,6 +392,12 @@ <h2>Variable</h2>
<div id="map-container">
<div id="map"></div>
<div id="loading-overlay">Loading data from S3...</div>
<div id="region-panel" hidden>
<div id="region-body">
<div id="region-chart"></div>
<div id="region-legend"></div>
</div>
</div>
<div id="velocity-legend" hidden>
<div class="legend-title" id="velocity-legend-title"></div>
<div class="legend-row">
Expand Down
7 changes: 7 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"leaflet": "^1.9.4",
"proj4": "^2.15.0",
"proj4leaflet": "^1.0.2",
"uplot": "^1.6.32",
"zarrita": "^0.5.0"
},
"devDependencies": {
Expand Down
180 changes: 180 additions & 0 deletions web/src/histogram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import chroma from "chroma-js";

// Stable categorical season -> color. Sorted so the same season always gets
// the same color regardless of which subset is currently shown.
const SEASON_PALETTE = chroma.brewer.Set2.concat(chroma.brewer.Set1);
const ALL_COLOR = "#e8e8e8";

export function seasonColors(seasons: string[]): Map<string, string> {
const sorted = Array.from(new Set(seasons)).sort();
const m = new Map<string, string>();
sorted.forEach((s, i) => m.set(s, SEASON_PALETTE[i % SEASON_PALETTE.length]));
return m;
}

const GRID_N = 256;

function percentile(sorted: number[], p: number): number {
const i = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * p)));
return sorted[i];
}

// Gaussian KDE over `grid`, normalized to unit area across the grid so curve
// shapes are comparable regardless of sample size.
function kde(values: number[], grid: number[]): number[] {
const n = values.length;
const out = new Array(grid.length).fill(0);
if (n < 2) return out;
const mean = values.reduce((a, b) => a + b, 0) / n;
let varSum = 0;
for (const v of values) varSum += (v - mean) ** 2;
const std = Math.sqrt(varSum / (n - 1));
if (std === 0) return out;
const bw = 1.06 * std * Math.pow(n, -0.2);
const inv = 1 / (bw * Math.sqrt(2 * Math.PI));
for (let g = 0; g < grid.length; g++) {
let s = 0;
const x = grid[g];
for (let i = 0; i < n; i++) {
const z = (x - values[i]) / bw;
s += Math.exp(-0.5 * z * z);
}
out[g] = (s * inv) / n;
}
// Trapezoidal renormalization to unit area over the plotted range.
let area = 0;
for (let g = 1; g < grid.length; g++) {
area += ((out[g] + out[g - 1]) / 2) * (grid[g] - grid[g - 1]);
}
if (area > 0) for (let g = 0; g < out.length; g++) out[g] /= area;
return out;
}

export interface HistSeries {
label: string;
color: string;
values: number[];
}

let plot: uPlot | null = null;

export function clearHistogram(legendEl?: HTMLElement): void {
if (plot) {
plot.destroy();
plot = null;
}
if (legendEl) legendEl.replaceChildren();
}

// Returns the row elements in series order so the chart's focus hook can
// highlight the matching entry on hover.
function buildLegend(legendEl: HTMLElement, series: HistSeries[]): HTMLElement[] {
legendEl.replaceChildren();
const rows: HTMLElement[] = [];
for (const s of series) {
const isAll = s.label === "All";
const row = document.createElement("div");
row.className = isAll ? "leg-row leg-all" : "leg-row";
const sw = document.createElement("span");
sw.className = "leg-swatch";
sw.style.borderTopColor = isAll ? ALL_COLOR : s.color;
sw.style.borderTopWidth = isAll ? "3px" : "2px";
row.appendChild(sw);
row.append(s.label);
legendEl.appendChild(row);
rows.push(row);
}
return rows;
}

// Rebuild the chart. `series` order is drawn as given; pass the cumulative
// "All" series last so it sits on top. A custom fixed legend is rendered into
// `legendEl` (uPlot's own legend is disabled because its live values reflow
// and shift the canvas under the cursor).
export function renderHistogram(
container: HTMLElement,
legendEl: HTMLElement,
title: string,
series: HistSeries[],
): void {
clearHistogram(legendEl);
container.replaceChildren();

// "All" is the union of the shown seasons; use it for the axis range so a
// single-season store (no per-season series) still works.
const allSeries = series.find((s) => s.label === "All") ?? series[series.length - 1];
const usable = (allSeries?.values ?? []).filter((v) => !isNaN(v));
if (usable.length < 2) {
const msg = document.createElement("div");
msg.className = "region-empty";
msg.textContent = "No data for the selected seasons in this region.";
container.appendChild(msg);
return;
}

usable.sort((a, b) => a - b);
const lo = percentile(usable, 0.02);
const hi = percentile(usable, 0.98);
const xmin = lo;
const xmax = hi === lo ? lo + 1 : hi;
const step = (xmax - xmin) / (GRID_N - 1);
const grid = Array.from({ length: GRID_N }, (_, i) => xmin + i * step);

const data: uPlot.AlignedData = [
grid,
...series.map((s) => kde(s.values.filter((v) => !isNaN(v)), grid)),
];

const uSeries: uPlot.Series[] = [
{},
...series.map((s) => {
const isAll = s.label === "All";
return {
label: s.label,
stroke: isAll ? ALL_COLOR : s.color,
width: isAll ? 3 : 1.5,
points: { show: false },
};
}),
];

const legRows = buildLegend(legendEl, series);

const w = container.clientWidth || 460;
const h = 260;
plot = new uPlot(
{
title,
width: w,
height: h,
// Custom legend (see buildLegend); focus dims other series on hover.
cursor: { drag: { x: false, y: false }, focus: { prox: 24 } },
focus: { alpha: 0.25 },
legend: { show: false },
hooks: {
setSeries: [
(_u, sIdx) => {
legRows.forEach((r, i) =>
r.classList.toggle("leg-hi", sIdx != null && i === sIdx - 1),
);
},
],
},
scales: { x: { time: false } },
axes: [
{ stroke: "#aaa", grid: { stroke: "rgba(255,255,255,0.08)" }, ticks: { stroke: "rgba(255,255,255,0.15)" } },
{
stroke: "#aaa",
grid: { stroke: "rgba(255,255,255,0.08)" },
ticks: { stroke: "rgba(255,255,255,0.15)" },
size: 50,
},
],
series: uSeries,
},
data,
container,
);
}
Loading
Loading