diff --git a/AGENTS.md b/AGENTS.md index 6255e1d76f..a42be8c0da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ How you should interact with the codebase When working with the ReVISit codebase, work only with the source code files available to you. If you need an external library, please ask for approval first (and include how well used the library is). Make sure to follow best practices for React and TypeScript development, including proper state management, component structuring, and code documentation. Pay extra attention to lifecycle methods and hooks to ensure optimal performance and avoid memory leaks, including any updates to existing code. If you encounter any issues or have suggestions for improvements, feel free to bring them up for discussion. Don't interact with git and GitHub directly; instead, focus on the code itself. I'll handle version control and repository management. Always check package.json for the scripts available to you for building, testing, and running the project. Testing -When adding a new feature or modifying code try to maximize unit test coverage. Unit tests should be colocated with the files they are testing, have the same names as the file with .spec., and use the vitest framework. Apply this to both UI/react code as well as non-UI code. For UI code, we use playwright for end-to-end testing. Try to add e2e tests for any new features that involve user interaction. E2E tests are located in the tests/ directory at the root of the project. Don't run `yarn test` directly; instead, describe the tests you want to run, and I'll handle executing them. You can run unittests locally using `yarn unittest`. Preferred commands are those listed in package.json (e.g., `yarn unittest`, `yarn lint`, `yarn serve`, `yarn build`). +When adding a new feature or modifying code try to maximize unit test coverage. Unit tests should be colocated with the files they are testing, have the same names as the file with .spec., and use the vitest framework. Apply this to both UI/react code as well as non-UI code. For UI code, we use playwright for end-to-end testing. Try to add e2e tests for any new features that involve user interaction. E2E tests are located in the tests/ directory at the root of the project. Don't run `yarn test` directly; instead, describe the tests you want to run, and I'll handle executing them. You can run unittests locally using `yarn unittest`. Preferred commands are those listed in package.json (e.g., `yarn unittest`, `yarn lint`, `yarn typecheck`, `yarn serve`, `yarn build`). Parser When adding new features, sometimes it's required to update the parser and the associated types. The parser is located in src/parser/. The parser is responsible for validating and transforming the study config JSON files into a format that the application can use. When updating the parser, ensure that you also update the corresponding types in src/parser/types.ts to reflect any changes made to the study config schema. Make sure to add unit tests for any new parser functionality to ensure its correctness. Additionally, changes to the parser types will require updates to the generated JSON schema files located in src/schemas/. You can regenerate these schema files by running `yarn generate-schemas`. \ No newline at end of file diff --git a/package.json b/package.json index 7aa4be8eb3..4369ddd679 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "buildDocs": "typedoc", "serve": "vite --host=0.0.0.0 --port=8080", + "typecheck": "tsc --noEmit", "build": "tsc && vite build", "lint": "eslint src", "generate-schema:StudyConfig": "ts-json-schema-generator --path src/parser/types.ts --out src/parser/StudyConfigSchema.json --type StudyConfig --no-type-check", diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 14c91d2143..68ef037793 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -4,7 +4,8 @@ import { AppShell, Button, Center, - Group, Popover, SegmentedControl, Select, Stack, Text, + ColorSwatch, + Group, HoverCard, Popover, SegmentedControl, Select, Stack, Text, Tooltip, } from '@mantine/core'; import { useSearchParams } from 'react-router'; @@ -14,7 +15,7 @@ import { import * as d3 from 'd3'; import { - IconArrowLeft, IconArrowRight, IconDeviceDesktopDown, IconInfoCircle, IconMusicDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRestore, + IconArrowLeft, IconArrowRight, IconDeviceDesktopDown, IconInfoCircle, IconMusicDown, IconPalette, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRestore, } from '@tabler/icons-react'; import { useAsync } from '../../../store/hooks/useAsync'; import { useAuth } from '../../../store/hooks/useAuth'; @@ -31,6 +32,9 @@ import { handleTaskAudio, handleTaskScreenRecording } from '../../../utils/handl import { ParticipantRejectModal } from '../ParticipantRejectModal'; import { StorageEngine } from '../../../storage/engines/types'; import { useReplayContext } from '../../../store/hooks/useReplay'; +import { + buildProvenanceLegendEntries, +} from '../../../components/audioAnalysis/provenanceColors'; const margin = { left: 5, top: 0, right: 5, bottom: 0, @@ -278,6 +282,15 @@ export function ThinkAloudFooter({ const [timeString, setTimeString] = useState(''); + const provenanceLegendEntries = useMemo(() => { + const answer = participant?.answers[currentTrial]; + if (!answer?.provenanceGraph) { + return new Map(); + } + + return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph)); + }, [participant, currentTrial]); + return ( {currentTrial && participant && currentTrialClean === '' && ( @@ -299,7 +312,7 @@ export function ThinkAloudFooter({ {timeString} - { setIsPlaying(!isPlaying); }}> + { setIsPlaying(!isPlaying); }}> {hasEnded ? : isPlaying ? : } @@ -485,6 +498,23 @@ export function ThinkAloudFooter({ )} + {provenanceLegendEntries.size > 1 && ( + + + + + + + {Array.from(provenanceLegendEntries.entries()).map(([key, value]) => ( + + + {value.label} + + ))} + + + + )} diff --git a/src/components/audioAnalysis/TaskProvenanceNodes.spec.tsx b/src/components/audioAnalysis/TaskProvenanceNodes.spec.tsx new file mode 100644 index 0000000000..b7e8ca5b54 --- /dev/null +++ b/src/components/audioAnalysis/TaskProvenanceNodes.spec.tsx @@ -0,0 +1,106 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import * as d3 from 'd3'; +import { describe, expect, test } from 'vitest'; +import { TrrackedProvenance } from '../../store/types'; +import { TaskProvenanceNodes } from './TaskProvenanceNodes'; +import { getColorForKey } from './provenanceColors'; + +function getFills(markup: string): string[] { + return [...markup.matchAll(/fill="([^"]+)"/g)].map((match) => match[1]); +} + +function createGraph(nodes: TrrackedProvenance['nodes'], root: string): TrrackedProvenance { + return { + current: root, + root, + nodes, + } as TrrackedProvenance; +} + +describe('TaskProvenanceNodes', () => { + test('uses deterministic color per canonical action key regardless of node order', () => { + const rootNode = { + id: 'root', + label: 'Root', + createdOn: 0, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: ['n1'], + state: { type: 'checkpoint', val: {} }, + level: 0, + event: 'Root', + } as TrrackedProvenance['nodes'][string]; + const actionNode = { + id: 'n1', + label: 'Some label', + createdOn: 1, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'signal', + parent: 'root', + sideEffects: { do: [{ type: 'Signal/SetZoom' }], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + + const graphOne = createGraph({ root: rootNode, n1: actionNode }, 'root'); + const graphTwo = createGraph({ n1: actionNode, root: rootNode }, 'root'); + + const xScale = d3.scaleLinear([0, 100]).domain([0, 5]); + + const first = renderToStaticMarkup( + + + , + ); + + const second = renderToStaticMarkup( + + + , + ); + + expect(getFills(first).sort()).toEqual(getFills(second).sort()); + expect(getFills(first)).toContain(getColorForKey('signal setzoom')); + }); + + test('active-node overlay uses the same color as its base node', () => { + const rootNode = { + id: 'root', + label: 'Root', + createdOn: 0, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: ['n1'], + state: { type: 'checkpoint', val: {} }, + level: 0, + event: 'Root', + } as TrrackedProvenance['nodes'][string]; + const actionNode = { + id: 'n1', + label: 'Some label', + createdOn: 1, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'signal', + parent: 'root', + sideEffects: { do: [{ type: 'Signal/SetZoom' }], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + + const graph = createGraph({ root: rootNode, n1: actionNode }, 'root'); + const xScale = d3.scaleLinear([0, 100]).domain([0, 5]); + const markup = renderToStaticMarkup( + + + , + ); + + const actionColor = getColorForKey('signal setzoom'); + const actionColorCount = getFills(markup).filter((fill) => fill === actionColor).length; + expect(actionColorCount).toBe(2); + }); +}); diff --git a/src/components/audioAnalysis/TaskProvenanceNodes.tsx b/src/components/audioAnalysis/TaskProvenanceNodes.tsx index bf77b0f4f5..b55c7257af 100644 --- a/src/components/audioAnalysis/TaskProvenanceNodes.tsx +++ b/src/components/audioAnalysis/TaskProvenanceNodes.tsx @@ -1,39 +1,15 @@ import * as d3 from 'd3'; - -import { - Affix, -} from '@mantine/core'; -import { useMemo } from 'react'; -import { StoredAnswer, TrrackedProvenance } from '../../store/types'; +import { TrrackedProvenance } from '../../store/types'; +import { getNodeColor } from './provenanceColors'; const RECT_HEIGHT = 15; const RECT_WIDTH = 3; -const colorPlatte = ['#4269d0', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7', '#a463f2', '#97bbf5', '#9c6b4e']; - export function TaskProvenanceNodes({ - answer, height, xScale, currentNode, provenance, + height, xScale, currentNode, provenance, }: { - answer: StoredAnswer, height: number, xScale: d3.ScaleLinear, currentNode: string | null, provenance: TrrackedProvenance + height: number, xScale: d3.ScaleLinear, currentNode: string | null, provenance: TrrackedProvenance }) { - const colorMap = useMemo(() => { - const _colorMap = new Map(); - _colorMap.set('Root', '#efb118'); - if (answer.provenanceGraph) { - let idx = 0; - Object.entries(answer.provenanceGraph.stimulus?.nodes || {}) - .forEach((entry) => { - const [, node] = entry; - if (!_colorMap.has(node.label)) { - _colorMap.set(node.label, colorPlatte[idx]); - idx = (idx + 1) % colorPlatte.length; - } - }); - } - - return _colorMap; - }, [answer]); - return ( {/* Provenance nodes */} @@ -41,33 +17,14 @@ export function TaskProvenanceNodes({ const [nodeId, node] = entry; return ( - + ); }) : null} {/* Currently active provenance node */} {currentNode && provenance && provenance.nodes[currentNode] && ( - + )} - - {/* - - - - - - { - Array.from(colorMap.keys()).map((key) => ( - - - {key} - - )) - } - - - */} - ); } diff --git a/src/components/audioAnalysis/TaskProvenanceTimeline.tsx b/src/components/audioAnalysis/TaskProvenanceTimeline.tsx index 44df321ba0..4727cbf9d9 100644 --- a/src/components/audioAnalysis/TaskProvenanceTimeline.tsx +++ b/src/components/audioAnalysis/TaskProvenanceTimeline.tsx @@ -47,7 +47,6 @@ export function TaskProvenanceTimeline({ if (graph) { return ( { + test('normalizeActionName collapses punctuation and whitespace consistently', () => { + expect(normalizeActionName('Zoom In')).toBe('zoom in'); + expect(normalizeActionName(' zoom-in ')).toBe('zoom in'); + expect(normalizeActionName('ZOOM IN')).toBe('zoom in'); + }); + + test('getNodeColorKey uses registry action type first', () => { + const node = { + id: 'n1', + label: 'Different Label', + createdOn: 1, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'SomeEvent', + parent: 'root', + sideEffects: { + do: [{ type: 'Signal/SetZoom' }], + undo: [], + }, + } as TrrackedProvenance['nodes'][string]; + + expect(getNodeColorKey(node)).toBe('signal setzoom'); + }); + + test('getNodeColorKey falls back to event, then label, and handles root', () => { + const eventNode = { + id: 'n1', + label: '', + createdOn: 1, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'BrushMove', + parent: 'root', + sideEffects: { do: [], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + expect(getNodeColorKey(eventNode)).toBe('brushmove'); + + const labelNode = { + id: 'n2', + label: ' Zoom In ', + createdOn: 2, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: '', + parent: 'root', + sideEffects: { do: [], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + expect(getNodeColorKey(labelNode)).toBe('zoom in'); + + const rootNode = { + id: 'root', + label: 'Root', + createdOn: 0, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 0, + event: 'Root', + } as TrrackedProvenance['nodes'][string]; + expect(getNodeColorKey(rootNode)).toBe(ROOT_KEY); + }); + + test('getColorForKey is deterministic and root key always uses root color', () => { + const key = 'signal setzoom'; + expect(getColorForKey(key)).toBe(getColorForKey(key)); + expect(getColorForKey(ROOT_KEY)).toBe(ROOT_COLOR); + expect(getColorForKey('update')).toBe(FORM_UPDATE_COLOR); + expect(getColorForKey('update form field')).toBe(FORM_UPDATE_COLOR); + }); + + test('different keys generally map to different colors', () => { + expect(getColorForKey('action 2')).not.toBe(getColorForKey('action 3')); + }); + + test('buildProvenanceLegendEntries de-dupes by canonical key across locations', () => { + const rootNode = { + id: 'root', + label: 'Root', + createdOn: 0, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: ['a1'], + state: { type: 'checkpoint', val: {} }, + level: 0, + event: 'Root', + } as TrrackedProvenance['nodes'][string]; + const locationOneAction = { + id: 'a1', + label: 'Zoom In', + createdOn: 1, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'signal', + parent: 'root', + sideEffects: { do: [{ type: 'Signal/SetZoom' }], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + const locationTwoAction = { + id: 'b1', + label: ' zoom-in ', + createdOn: 2, + artifacts: [], + meta: { annotation: [], bookmark: [] }, + children: [], + state: { type: 'checkpoint', val: {} }, + level: 1, + event: 'other', + parent: 'root', + sideEffects: { do: [{ type: 'Signal/SetZoom' }], undo: [] }, + } as TrrackedProvenance['nodes'][string]; + + const graphA = createGraph({ root: rootNode, a1: locationOneAction }, 'root'); + const graphB = createGraph({ root: rootNode, b1: locationTwoAction }, 'root'); + + const legendEntries = buildProvenanceLegendEntries([graphA, graphB]); + expect(legendEntries.size).toBe(2); + expect(legendEntries.get('signal setzoom')?.color).toBe(getColorForKey('signal setzoom')); + }); +}); diff --git a/src/components/audioAnalysis/provenanceColors.ts b/src/components/audioAnalysis/provenanceColors.ts new file mode 100644 index 0000000000..c927b9c148 --- /dev/null +++ b/src/components/audioAnalysis/provenanceColors.ts @@ -0,0 +1,145 @@ +/* eslint-disable no-bitwise */ +import { TrrackedProvenance } from '../../store/types'; + +export const PROVENANCE_COLOR_PALETTE = ['#4269d0', '#ff725c', '#6cc5b0', '#3ca951', '#ff8ab7', '#a463f2', '#97bbf5', '#9c6b4e']; +export const ROOT_COLOR = '#efb118'; +export const UNKNOWN_COLOR = '#9498a0'; +export const FORM_UPDATE_COLOR = '#9498a0'; + +export const ROOT_KEY = '__root__'; +export const UNKNOWN_KEY = '__unknown__'; +export const FORM_UPDATE_KEY = 'update'; + +type ProvenanceNode = TrrackedProvenance['nodes'][string]; +type ProvenanceGraph = TrrackedProvenance | undefined; + +function normalizeKeyText(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function normalizeActionName(name: string): string { + return normalizeKeyText(name); +} + +function getNodeActionType(node: ProvenanceNode, normalize = false): string | null { + if ('sideEffects' in node && node.sideEffects?.do?.length > 0) { + const firstType = node.sideEffects.do.find((action) => typeof action?.type === 'string')?.type; + if (firstType) { + return normalize ? normalizeKeyText(firstType) : firstType.trim(); + } + } + return null; +} + +export function getNodeColorKey(node: ProvenanceNode): string { + if (node.event === 'Root') { + return ROOT_KEY; + } + + const actionType = getNodeActionType(node, true); + if (actionType) { + return actionType; + } + + if ('event' in node && typeof node.event === 'string' && node.event !== 'Root') { + const normalizedEvent = normalizeKeyText(node.event); + if (normalizedEvent) { + return normalizedEvent; + } + } + + if (typeof node.label === 'string') { + const normalizedLabel = normalizeActionName(node.label); + if (normalizedLabel) { + return normalizedLabel; + } + } + + return UNKNOWN_KEY; +} + +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +} + +function isFormUpdateKey(key: string): boolean { + return key === FORM_UPDATE_KEY || key === normalizeKeyText('Update form field'); +} + +export function getColorForKey(key: string): string { + if (key === ROOT_KEY) { + return ROOT_COLOR; + } + if (!key || key === UNKNOWN_KEY) { + return UNKNOWN_COLOR; + } + + if (isFormUpdateKey(key)) { + return FORM_UPDATE_COLOR; + } + + // Use deterministic HSL from a 32-bit hash to greatly reduce collisions + // compared to a fixed-size palette while keeping colors readable. + const hash = hashString(key); + const hue = hash % 360; + const saturation = 60 + ((hash >>> 9) % 21); // 60-80 + const lightness = 35 + ((hash >>> 17) % 21); // 35-55 + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + +export function getNodeColor(node: ProvenanceNode): string { + return getColorForKey(getNodeColorKey(node)); +} + +export function getNodeDisplayLabel(node: ProvenanceNode): string { + const label = typeof node.label === 'string' ? node.label.trim() : ''; + if (label) { + return label; + } + + const actionType = getNodeActionType(node, false); + if (actionType) { + return actionType; + } + + if ('event' in node && typeof node.event === 'string') { + const eventName = node.event.trim(); + if (eventName) { + return eventName; + } + } + + return UNKNOWN_KEY; +} + +export function buildProvenanceLegendEntries(graphs: ProvenanceGraph[]): Map { + const entries = new Map(); + + graphs.forEach((graph) => { + if (!graph?.nodes) { + return; + } + + Object.values(graph.nodes).forEach((node) => { + const key = getNodeColorKey(node); + if (!entries.has(key)) { + entries.set(key, { + label: getNodeDisplayLabel(node), + color: getColorForKey(key), + }); + } + }); + }); + + return entries; +} diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 2b9bb8f50b..dfa6f70005 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -194,7 +194,7 @@ export function ResponseBlock({ }, [matrixAnswers, rankingAnswers]); useEffect(() => { - trrack.apply('update', actions.updateFormAction(structuredClone(answerValidator.values))); + trrack.apply('Update form field', actions.updateFormAction(structuredClone(answerValidator.values))); storeDispatch( updateResponseBlockValidation({