Skip to content

Commit 8063573

Browse files
authored
Merge pull request #31 from patcon/feat/29-metrics-floating-modal-legend
Show FloatingModal legend for obs-column annotations in metrics layer
2 parents a4d5689 + f9a543d commit 8063573

File tree

7 files changed

+159
-47
lines changed

7 files changed

+159
-47
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
### Added
66

7+
- `FloatingModal` legend when viewing an obs-column annotation in the metrics layer ([#29](https://github.com/patcon/polislike-human-cartography-prototype-v2/issues/29)).
8+
- Shows the column name as the label and colored category swatches for categorical columns; continuous columns show an empty modal (legend to follow).
9+
- Prev/next arrows (and ←/→ keyboard shortcuts) cycle through available obs columns, updating the active annotation.
10+
- X button returns to the groups layer, matching the statement-modal behavior.
11+
- Extended `FloatingModal` with optional `title` and `legendItems` props for the annotation rendering path; existing statement rendering is unchanged.
12+
- Categorical annotation layers use a dedicated Tableau 20 palette (20 colors) separate from the 10-color painting palette, giving more range without affecting group colors.
13+
- Legend is hidden for columns with >65 categories (e.g. timestamps, UUIDs) — only the column title is shown, matching continuous column behavior.
14+
- Blank category labels are displayed as **N/A**.
715
- Prev/next navigation buttons on `FloatingModal` for touch-friendly statement cycling in votes layer mode ([#27](https://github.com/patcon/polislike-human-cartography-prototype-v2/issues/27)).
816
- Extracted shared `cycleStatement` callback so keyboard arrow keys and buttons use the same logic.
917
- `onPrev` / `onNext` are optional injected props — buttons only render when provided, keeping the modal reusable for other contexts.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Tests use Vitest with jsdom. `src/test-setup.ts` mocks `matchMedia`, `ResizeObse
7878
`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com) conventions with `Added`, `Changed`, and `Fixed` subsections under each release.
7979

8080
- **Every feature branch must include a `CHANGELOG.md` update** in the same commit (or PR) as the code change.
81+
- **Claude must always update `CHANGELOG.md`** when implementing any feature, fix, or notable change — without waiting to be asked.
8182
- Entries go under `## [Unreleased]` until a version is tagged.
8283
- Link each entry to its GitHub issue (`[#27](.../issues/27)`) if one exists, otherwise fall back to the PR (`[#42](.../pull/42)`).
8384
- On release: rename `[Unreleased]` to the new version + date, update its compare URL to `compare/vPREV...vNEW`, and add a fresh empty `[Unreleased]` block at the top pointing to `compare/vNEW...main`.

src/components/convo-explorer/App.tsx

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useShiftKeyTempMode } from "../../hooks/useShiftKeyTempMode";
2323
import { useLayerModeCycling } from "../../hooks/useLayerModeCycling";
2424
import type { MetricConfig } from "./MetricsLayerConfig";
2525
import type { ObsColumnType } from "@/lib/color-schemes";
26+
import { getAnnotationCategoricalColor } from "@/lib/color-schemes";
2627
import type { ObsColumnInfo } from "@/lib/h5ad-loader";
2728

2829
// Helper function for ID matching - can be optimized later for performance
@@ -291,6 +292,40 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
291292
}
292293
}, [currentDisplayState, currentPipelineId]);
293294

295+
// Derive obs column keys from preloaded data for the "Other" metrics option
296+
// Exclude the display mask column so it doesn't appear in the dropdown
297+
const obsColumnKeys = React.useMemo(() => {
298+
if (!preloadedData?.obsColumns) return undefined;
299+
const keys = Object.keys(preloadedData.obsColumns).filter(k => k !== DISPLAY_MASK_COLUMN);
300+
return keys.length > 0 ? keys : undefined;
301+
}, [preloadedData?.obsColumns]);
302+
303+
const cycleObsColumn = React.useCallback((direction: 'prev' | 'next') => {
304+
if (!obsColumnKeys || obsColumnKeys.length === 0) return;
305+
if (metricConfig.type !== 'obs-column') return;
306+
const currentIndex = obsColumnKeys.indexOf(metricConfig.column);
307+
if (currentIndex === -1) return;
308+
const newIndex = direction === 'prev'
309+
? (currentIndex === 0 ? obsColumnKeys.length - 1 : currentIndex - 1)
310+
: (currentIndex === obsColumnKeys.length - 1 ? 0 : currentIndex + 1);
311+
setMetricConfig({ type: 'obs-column', column: obsColumnKeys[newIndex] });
312+
}, [obsColumnKeys, metricConfig]);
313+
314+
// Derive legend items for the metrics FloatingModal (categorical columns only)
315+
const metricsLegendItems = React.useMemo(() => {
316+
if (metricConfig.type !== 'obs-column') return undefined;
317+
if (!preloadedData?.obsColumns) return undefined;
318+
const columnInfo = preloadedData.obsColumns[metricConfig.column];
319+
if (!columnInfo || columnInfo.type !== 'categorical') return undefined;
320+
const categories = columnInfo.categories ?? [];
321+
// Hide legend when there are too many categories to be useful
322+
if (categories.length > 65) return undefined;
323+
return categories.map((cat, i) => ({
324+
label: String(cat),
325+
color: getAnnotationCategoricalColor(i),
326+
}));
327+
}, [metricConfig, preloadedData?.obsColumns]);
328+
294329
const cycleStatement = React.useCallback((direction: 'prev' | 'next') => {
295330
if (statements.length === 0) return;
296331
const currentIndex = statements.findIndex(s => String(s.statement_id) === statementId);
@@ -320,6 +355,15 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
320355
}
321356
}
322357

358+
// Handle left/right arrow keys for obs column cycling when metrics layer is active
359+
if (layerMode === "metrics" && metricConfig.type === "obs-column" && obsColumnKeys && obsColumnKeys.length > 1) {
360+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
361+
cycleObsColumn(event.key === 'ArrowLeft' ? 'prev' : 'next');
362+
event.preventDefault();
363+
return;
364+
}
365+
}
366+
323367
// Handle number keys 1-9 and 0 for color selection
324368
if (event.key >= '1' && event.key <= '9') {
325369
const index = parseInt(event.key, 10);
@@ -342,7 +386,7 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
342386
return () => {
343387
document.removeEventListener('keydown', handleKeyDown);
344388
};
345-
}, [layerMode, statements, statementId, cycleStatement]);
389+
}, [layerMode, statements, statementId, cycleStatement, metricConfig, obsColumnKeys, cycleObsColumn]);
346390

347391
// Initialize point arrays when dataset is loaded
348392
React.useEffect(() => {
@@ -623,14 +667,6 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
623667
// Vote stats calculation removed from App level - now handled in StatementExplorerDrawer
624668
// This avoids calculating stats for all statements when only group tab statements need them
625669

626-
// Derive obs column keys from preloaded data for the "Other" metrics option
627-
// Exclude the display mask column so it doesn't appear in the dropdown
628-
const obsColumnKeys = React.useMemo(() => {
629-
if (!preloadedData?.obsColumns) return undefined;
630-
const keys = Object.keys(preloadedData.obsColumns).filter(k => k !== DISPLAY_MASK_COLUMN);
631-
return keys.length > 0 ? keys : undefined;
632-
}, [preloadedData?.obsColumns]);
633-
634670
// Derive display mask array (parallel to dataset) from obs column
635671
const displayMask = React.useMemo(() => {
636672
if (!preloadedData?.obsColumns) return undefined;
@@ -928,6 +964,18 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
928964
/>
929965
)}
930966

967+
{/* FloatingModal - shows annotation legend in metrics mode (obs-column) */}
968+
{layerMode === "metrics" && metricConfig.type === "obs-column" && (
969+
<FloatingModal
970+
title={metricConfig.column}
971+
legendItems={metricsLegendItems}
972+
isVisible={true}
973+
onClose={() => setLayerMode("groups")}
974+
onPrev={obsColumnKeys && obsColumnKeys.length > 1 ? () => cycleObsColumn('prev') : undefined}
975+
onNext={obsColumnKeys && obsColumnKeys.length > 1 ? () => cycleObsColumn('next') : undefined}
976+
/>
977+
)}
978+
931979
{/* Clear Colors Dialog */}
932980
<ClearColorsDialog
933981
open={clearDialogOpen}

src/components/convo-explorer/D3Map.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as React from "react";
44
import * as d3 from "d3";
55
import { PALETTE_COLORS, UNPAINTED_COLOR, UNPAINTED_VALUE, OUTLINE_RADIUS, OUTLINE_OPACITY, OUTLINE_SUSPEND_DURING_ANIMATION } from "@/constants";
66
import type { ObsColumnType } from "@/lib/color-schemes";
7-
import { BOOLEAN_COLORS, NULL_COLOR, createContinuousScale, getCategoricalColor } from "@/lib/color-schemes";
7+
import { BOOLEAN_COLORS, NULL_COLOR, createContinuousScale, getAnnotationCategoricalColor } from "@/lib/color-schemes";
88
import { usePipelineOptions } from "../../../.storybook/hooks/usePipelineOptions";
99
import { MapProjectionSelector } from "./MapProjectionSelector";
1010
import { Button } from "../ui/button";
@@ -270,7 +270,7 @@ export const D3Map: React.FC<D3MapProps> = ({
270270
case "boolean":
271271
return colorValue ? BOOLEAN_COLORS.true : BOOLEAN_COLORS.false;
272272
case "categorical":
273-
return getCategoricalColor(colorValue);
273+
return getAnnotationCategoricalColor(colorValue);
274274
case "continuous":
275275
default:
276276
return continuousColorScale(colorValue);

src/components/convo-explorer/FloatingModal.tsx

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,22 @@ type Statement = {
1010
moderated?: number;
1111
};
1212

13+
export type LegendItem = { label: string; color: string };
14+
1315
type FloatingModalProps = {
14-
statement: Statement;
16+
// Statement mode (existing)
17+
statement?: Statement;
18+
// Annotation/legend mode
19+
title?: string;
20+
legendItems?: LegendItem[];
1521
isVisible?: boolean;
1622
onClose?: () => void;
1723
onPrev?: () => void;
1824
onNext?: () => void;
1925
} & React.ComponentPropsWithoutRef<typeof Card>;
2026

2127
export const FloatingModal = React.forwardRef<HTMLDivElement, FloatingModalProps>(
22-
({ statement, isVisible = true, onClose, onPrev, onNext, className, ...props }, ref) => {
28+
({ statement, title, legendItems, isVisible = true, onClose, onPrev, onNext, className, ...props }, ref) => {
2329
if (!isVisible) return null;
2430

2531
const insertBreaks = (val: string | null | undefined) => {
@@ -80,36 +86,67 @@ export const FloatingModal = React.forwardRef<HTMLDivElement, FloatingModalProps
8086
</button>
8187
)}
8288

83-
{/* Statement content */}
84-
<div className="flex gap-2 items-start">
85-
{/* Statement ID */}
86-
<div className="flex-shrink-0 pt-0.5">
87-
<span className={`text-gray-400 text-[12px] font-mono inline-block text-right ${hasNav ? "min-w-[3rem]" : "w-10"}`}>
88-
#{statement.statement_id}
89-
</span>
90-
</div>
89+
{/* Content: statement mode or annotation/legend mode */}
90+
{statement ? (
91+
<div className="flex gap-2 items-start">
92+
{/* Statement ID */}
93+
<div className="flex-shrink-0 pt-0.5">
94+
<span className={`text-gray-400 text-[12px] font-mono inline-block text-right ${hasNav ? "min-w-[3rem]" : "w-10"}`}>
95+
#{statement.statement_id}
96+
</span>
97+
</div>
9198

92-
{/* Statement Text */}
93-
<div className="flex-1 min-w-0">
94-
<span
95-
key={statement.statement_id}
96-
translate="yes"
97-
className={`text-sm leading-relaxed ${
98-
statement.moderated === -1 ? "text-red-700" : ""
99-
} ${
100-
statement.moderated === 0 ? "text-gray-500" : ""
101-
} ${
102-
statement.moderated === 1 ? "text-gray-900 dark:text-gray-100" : ""
103-
} ${
104-
statement.moderated === undefined ? "text-gray-900 dark:text-gray-100" : ""
105-
}`}
106-
>
107-
{insertBreaks(statement.txt)}
108-
{statement.moderated === -1 ? " (moderated)" : ""}
109-
{statement.moderated === 0 ? " (unmoderated)" : ""}
110-
</span>
99+
{/* Statement Text */}
100+
<div className="flex-1 min-w-0">
101+
<span
102+
key={statement.statement_id}
103+
translate="yes"
104+
className={`text-sm leading-relaxed ${
105+
statement.moderated === -1 ? "text-red-700" : ""
106+
} ${
107+
statement.moderated === 0 ? "text-gray-500" : ""
108+
} ${
109+
statement.moderated === 1 ? "text-gray-900 dark:text-gray-100" : ""
110+
} ${
111+
statement.moderated === undefined ? "text-gray-900 dark:text-gray-100" : ""
112+
}`}
113+
>
114+
{insertBreaks(statement.txt)}
115+
{statement.moderated === -1 ? " (moderated)" : ""}
116+
{statement.moderated === 0 ? " (unmoderated)" : ""}
117+
</span>
118+
</div>
111119
</div>
112-
</div>
120+
) : (
121+
<div className="flex flex-col gap-2 pl-10">
122+
{/* Annotation label */}
123+
{title && (
124+
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide leading-none text-center w-full block">
125+
{title}
126+
</span>
127+
)}
128+
{/* Category swatches */}
129+
{legendItems && legendItems.length > 0 && (
130+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
131+
{legendItems.map(({ label, color }) => {
132+
const isBlank = label.trim() === "";
133+
const displayLabel = isBlank ? "N/A" : label;
134+
return (
135+
<div key={label} className="flex items-center gap-1.5">
136+
<span
137+
className="inline-block w-3 h-3 rounded-sm flex-shrink-0"
138+
style={{ backgroundColor: color }}
139+
/>
140+
<span className="text-xs text-gray-800 dark:text-gray-200">
141+
{displayLabel}
142+
</span>
143+
</div>
144+
);
145+
})}
146+
</div>
147+
)}
148+
</div>
149+
)}
113150
</Card>
114151
);
115152
}

src/components/convo-explorer/MetricsLayerConfig.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import * as React from "react";
55
import { Label } from "@/components/ui/label";
66
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
7-
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
87
import { Input } from "@/components/ui/input";
98
import {
109
Select,
@@ -64,11 +63,11 @@ export function MetricsLayerConfig({
6463
}
6564
};
6665

67-
const handleStyleChange = (style: string) => {
68-
if (config.type === "vote-count" && (style === "color" || style === "opacity")) {
69-
onConfigChange?.({ type: "vote-count", style });
70-
}
71-
};
66+
// const handleStyleChange = (style: string) => {
67+
// if (config.type === "vote-count" && (style === "color" || style === "opacity")) {
68+
// onConfigChange?.({ type: "vote-count", style });
69+
// }
70+
// };
7271

7372
const handleObsColumnChange = (column: string) => {
7473
setLastObsColumn(column);

src/lib/color-schemes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,22 @@ export function createContinuousScale() {
1818
export function getCategoricalColor(index: number): string {
1919
return PALETTE_COLORS[index % PALETTE_COLORS.length];
2020
}
21+
22+
// Annotation categorical palette — Tableau 20 (the standard 10 + their lighter paired variants).
23+
// Gives more range for obs-column annotation layers without touching the painting palette.
24+
export const ANNOTATION_PALETTE_COLORS: string[] = [
25+
"#1f77b4", "#aec7e8", // blue
26+
"#ff7f0e", "#ffbb78", // orange
27+
"#2ca02c", "#98df8a", // green
28+
"#d62728", "#ff9896", // red
29+
"#9467bd", "#c5b0d5", // purple
30+
"#8c564b", "#c49c94", // brown
31+
"#e377c2", "#f7b6d2", // pink
32+
"#7f7f7f", "#c7c7c7", // gray
33+
"#bcbd22", "#dbdb8d", // lime
34+
"#17becf", "#9edae5", // teal
35+
];
36+
37+
export function getAnnotationCategoricalColor(index: number): string {
38+
return ANNOTATION_PALETTE_COLORS[index % ANNOTATION_PALETTE_COLORS.length];
39+
}

0 commit comments

Comments
 (0)