Skip to content

Commit 17659e8

Browse files
feat(app): legend + colors follow the highest-weighted category
Until now the colored borders and the legend were hardcoded to the top-7 plot_types. With the weight sliders now letting the user dial up any category, the same buckets stayed colored even when "styling" became the dominant similarity signal — useful colors but mismatched to the actual clustering driver. Compute an `activeCategory` from the current weights (max wins; ties go to plot_type because it's first in TAG_CATEGORIES and we use strictly-greater compare). The legend now shows the active category name as a caption + the top-7 values from THAT category with their counts, and node border colors track the same buckets. Falls back to plot_type when all weights are zero. Refactored the helpers to be category-generic: - primaryCategoryValue(spec, category) — replaces primaryPlotType, with primaryPlotType kept as a thin wrapper for compatibility. - categoryValueCounts(specs, category) — replaces plotTypeCounts. - topCategoryValues(specs, category, n) — replaces topPlotTypes. - SPEC_LEVEL_CATEGORIES const distinguishes spec-level (plot_type, features, data_type, domain) from impl-level (the rest) so the helper looks up the right tag dict on the spec. Refs #5646 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db24af7 commit 17659e8

2 files changed

Lines changed: 84 additions & 30 deletions

File tree

app/src/pages/MapPage.helpers.ts

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -282,42 +282,77 @@ export function pickBestLoadedTier(
282282
return null;
283283
}
284284

285+
/** Tag categories that come from specification.yaml (vs. impl-level metadata). */
286+
export const SPEC_LEVEL_CATEGORIES: readonly TagCategory[] = [
287+
'plot_type',
288+
'features',
289+
'data_type',
290+
'domain',
291+
] as const;
292+
285293
/**
286-
* Pick a spec's primary plot type. We use the first entry of the spec-level
287-
* `plot_type` tag list — that's the canonical type the spec is filed under.
288-
* Specs without any plot_type fall into a synthetic "other" cluster.
294+
* Pick a spec's primary value for a given tag category — the first entry of
295+
* the relevant list (spec.tags[category] for spec-level categories,
296+
* spec.impl_tags[category] for impl-level). Falls back to "other" when the
297+
* spec has no tag in that category at all.
289298
*/
299+
export function primaryCategoryValue(spec: SpecMapItem, category: TagCategory): string {
300+
const dict = (SPEC_LEVEL_CATEGORIES as readonly string[]).includes(category)
301+
? spec.tags
302+
: spec.impl_tags;
303+
return dict?.[category]?.[0] ?? 'other';
304+
}
305+
306+
/** Convenience wrapper: a spec's primary plot_type. */
290307
export function primaryPlotType(spec: SpecMapItem): string {
291-
return spec.tags?.plot_type?.[0] ?? 'other';
308+
return primaryCategoryValue(spec, 'plot_type');
292309
}
293310

294311
/**
295-
* Count specs by their primary plot_type (excluding the synthetic `other`
296-
* bucket). Used by the legend to display per-cluster member counts.
312+
* Count specs by their primary value for a given tag category (excluding
313+
* the synthetic `other` bucket). Used by the legend to display per-cluster
314+
* member counts.
297315
*/
298-
export function plotTypeCounts(specs: SpecMapItem[]): Map<string, number> {
316+
export function categoryValueCounts(
317+
specs: SpecMapItem[],
318+
category: TagCategory
319+
): Map<string, number> {
299320
const counts = new Map<string, number>();
300321
for (const s of specs) {
301-
const pt = primaryPlotType(s);
302-
if (pt === 'other') continue;
303-
counts.set(pt, (counts.get(pt) ?? 0) + 1);
322+
const v = primaryCategoryValue(s, category);
323+
if (v === 'other') continue;
324+
counts.set(v, (counts.get(v) ?? 0) + 1);
304325
}
305326
return counts;
306327
}
307328

329+
/** Convenience wrapper: per-plot_type spec counts. */
330+
export function plotTypeCounts(specs: SpecMapItem[]): Map<string, number> {
331+
return categoryValueCounts(specs, 'plot_type');
332+
}
333+
308334
/**
309-
* Return the top-N most frequent primary plot types in the corpus, sorted by
310-
* count descending (alphabetic name as tiebreaker for determinism). Used to
311-
* decide which buckets earn a distinct color border in the map.
335+
* Return the top-N most frequent primary values in the given category, sorted
336+
* by count descending (alphabetic name as tiebreaker for determinism). Used
337+
* to decide which buckets earn a distinct color border in the map.
312338
*
313-
* Excludes the synthetic `other` bucket (which only appears when a spec has
314-
* no plot_type tag at all) so it never wastes a color slot.
339+
* Excludes the synthetic `other` bucket (specs missing the category entirely)
340+
* so it never wastes a color slot.
315341
*/
316-
export function topPlotTypes(specs: SpecMapItem[], n: number): string[] {
317-
return Array.from(plotTypeCounts(specs).entries())
342+
export function topCategoryValues(
343+
specs: SpecMapItem[],
344+
category: TagCategory,
345+
n: number
346+
): string[] {
347+
return Array.from(categoryValueCounts(specs, category).entries())
318348
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
319349
.slice(0, n)
320-
.map(([t]) => t);
350+
.map(([v]) => v);
351+
}
352+
353+
/** Convenience wrapper: top-N plot_types by spec count. */
354+
export function topPlotTypes(specs: SpecMapItem[], n: number): string[] {
355+
return topCategoryValues(specs, 'plot_type', n);
321356
}
322357

323358
/**

app/src/pages/MapPage.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { specPath } from '../utils/paths';
1515
import { colors, fontSize, typography } from '../theme';
1616
import {
1717
buildKNNLinks,
18+
categoryValueCounts,
1819
computeIDF,
1920
DEFAULT_CATEGORY_WEIGHT,
2021
ensureNodeTier,
@@ -23,12 +24,11 @@ import {
2324
nodeAspectRatio,
2425
pickBestLoadedTier,
2526
pickTier,
26-
plotTypeCounts,
2727
preloadImages,
28-
primaryPlotType,
28+
primaryCategoryValue,
2929
selectMapThumbUrl,
3030
TAG_CATEGORIES,
31-
topPlotTypes,
31+
topCategoryValues,
3232
type MapLink,
3333
type MapNode,
3434
type ResolutionTier,
@@ -160,6 +160,22 @@ export function MapPage() {
160160
return () => obs.disconnect();
161161
}, []);
162162

163+
// The category that drives the legend + node border colors: whichever
164+
// currently has the highest weight (plot_type wins on ties because it's
165+
// the first entry of TAG_CATEGORIES and we use strictly-greater compare).
166+
// Falls back to plot_type when all weights are 0.
167+
const activeCategory: TagCategory = useMemo(() => {
168+
let maxWeight = -Infinity;
169+
let active: TagCategory = 'plot_type';
170+
for (const c of TAG_CATEGORIES) {
171+
if (weights[c] > maxWeight) {
172+
maxWeight = weights[c];
173+
active = c;
174+
}
175+
}
176+
return maxWeight > 0 ? active : 'plot_type';
177+
}, [weights]);
178+
163179
// 3. derive graph data from specs/theme (pure — no setState in effect)
164180
const graphData = useMemo<{
165181
nodes: MapNode[];
@@ -169,23 +185,23 @@ export function MapPage() {
169185
}>(() => {
170186
if (!specs) return { nodes: [], links: [], topTypes: [], typeCounts: new Map() };
171187
const idf = computeIDF(specs);
172-
const topTypes = topPlotTypes(specs, CLUSTER_COLORS.length);
173-
const typeCounts = plotTypeCounts(specs);
188+
const topTypes = topCategoryValues(specs, activeCategory, CLUSTER_COLORS.length);
189+
const typeCounts = categoryValueCounts(specs, activeCategory);
174190
const nodes: MapNode[] = specs.map(s => {
175-
const pt = primaryPlotType(s);
191+
const v = primaryCategoryValue(s, activeCategory);
176192
return {
177193
id: s.id,
178194
title: s.title,
179195
tags: flattenTags(s),
180-
colorBucket: topTypes.includes(pt) ? pt : null,
196+
colorBucket: topTypes.includes(v) ? v : null,
181197
thumbUrl: selectMapThumbUrl(s, isDark),
182198
imgs: new Map(),
183199
pendingTiers: new Set(),
184200
};
185201
});
186202
const links = buildKNNLinks(specs, idf, KNN_K, KNN_MIN_SIM, weights);
187203
return { nodes, links, topTypes, typeCounts };
188-
}, [specs, isDark, weights]);
204+
}, [specs, isDark, weights, activeCategory]);
189205

190206
// Eager-load the 400-tier thumbnails so something paints fast. Higher tiers
191207
// are fetched lazily from nodeCanvasObject when the user zooms in.
@@ -275,10 +291,12 @@ export function MapPage() {
275291
)}
276292
</Box>
277293

278-
{/* Legend: one row per top-N plot type with its cluster color and
279-
spec count. Hovering a row highlights that cluster on the canvas
280-
(matching nodes stay opaque, others dim) so the spatial shape of
281-
the cluster pops out even when nodes are scattered. */}
294+
{/* Legend: one row per top-N value of the highest-weighted tag
295+
category. Caption shows which category is active so it's obvious
296+
why the buckets just changed when a slider moves. Hovering a row
297+
highlights that cluster on the canvas (matching nodes stay opaque,
298+
others dim) so the spatial shape of the cluster pops out even
299+
when nodes are scattered. */}
282300
{graphData.topTypes.length > 0 && (
283301
<Box
284302
sx={{
@@ -294,6 +312,7 @@ export function MapPage() {
294312
color: 'var(--ink-soft)',
295313
}}
296314
>
315+
<Box sx={{ opacity: 0.6, mb: 0.25 }}>{activeCategory}</Box>
297316
{graphData.topTypes.map((t, i) => {
298317
const color = CLUSTER_COLORS[i % CLUSTER_COLORS.length];
299318
const count = graphData.typeCounts.get(t) ?? 0;

0 commit comments

Comments
 (0)