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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 33 additions & 3 deletions src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -278,6 +282,15 @@ export function ThinkAloudFooter({

const [timeString, setTimeString] = useState<string>('');

const provenanceLegendEntries = useMemo(() => {
const answer = participant?.answers[currentTrial];
if (!answer?.provenanceGraph) {
return new Map<string, { label: string; color: string }>();
}

return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph));
}, [participant, currentTrial]);

return (
<AppShell.Footer zIndex={101} withBorder={false}>
{currentTrial && participant && currentTrialClean === '' && (
Expand All @@ -299,7 +312,7 @@ export function ThinkAloudFooter({
<Text ff="monospace" style={{ textAlign: 'right' }} mt="lg" c="dimmed">{timeString}</Text>

<Tooltip label={hasEnded ? 'Restart' : isPlaying ? 'Pause' : 'Play'}>
<ActionIcon mt={25} size="xl" variant="light" onClick={() => { setIsPlaying(!isPlaying); }}>
<ActionIcon mt={25} size="lg" variant="light" onClick={() => { setIsPlaying(!isPlaying); }}>
{hasEnded ? <IconRestore /> : isPlaying ? <IconPlayerPauseFilled /> : <IconPlayerPlayFilled />}
</ActionIcon>
</Tooltip>
Expand Down Expand Up @@ -485,6 +498,23 @@ export function ThinkAloudFooter({
)}
<ParticipantRejectModal selectedParticipants={[]} footer />
</Group>
{provenanceLegendEntries.size > 1 && (
<HoverCard width={160} position="top" withArrow shadow="md">
<HoverCard.Target>
<ActionIcon c="" size="lg" variant="light" mt="lg" style={{ cursor: 'default' }}><IconPalette /></ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap={6}>
{Array.from(provenanceLegendEntries.entries()).map(([key, value]) => (
<Group key={key} gap={8}>
<ColorSwatch color={value.color} size={12} />
<span style={{ fontSize: 12 }}>{value.label}</span>
</Group>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
</Stack>
</AppShell.Footer>
Expand Down
106 changes: 106 additions & 0 deletions src/components/audioAnalysis/TaskProvenanceNodes.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<svg>
<TaskProvenanceNodes height={25} xScale={xScale} currentNode={null} provenance={graphOne} />
</svg>,
);

const second = renderToStaticMarkup(
<svg>
<TaskProvenanceNodes height={25} xScale={xScale} currentNode={null} provenance={graphTwo} />
</svg>,
);

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(
<svg>
<TaskProvenanceNodes height={25} xScale={xScale} currentNode="n1" provenance={graph} />
</svg>,
);

const actionColor = getColorForKey('signal setzoom');
const actionColorCount = getFills(markup).filter((fill) => fill === actionColor).length;
expect(actionColorCount).toBe(2);
});
});
55 changes: 6 additions & 49 deletions src/components/audioAnalysis/TaskProvenanceNodes.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,30 @@
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<number, number>, currentNode: string | null, provenance: TrrackedProvenance
height: number, xScale: d3.ScaleLinear<number, number>, 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 (
<g style={{ cursor: 'pointer' }}>
{/* Provenance nodes */}
{provenance ? Object.entries(provenance.nodes || {}).map((entry) => {
const [nodeId, node] = entry;
return (
<g key={nodeId}>
<rect fill={colorMap.get(node.label) || '#9498a0'} opacity={node.id === currentNode ? 1 : 0.7} x={xScale(node.createdOn) - RECT_WIDTH / 2} y={height / 2 - RECT_HEIGHT / 2} width={RECT_WIDTH} height={RECT_HEIGHT} />
<rect fill={getNodeColor(node)} opacity={node.id === currentNode ? 1 : 0.7} x={xScale(node.createdOn) - RECT_WIDTH / 2} y={height / 2 - RECT_HEIGHT / 2} width={RECT_WIDTH} height={RECT_HEIGHT} />
</g>
);
}) : null}
{/* Currently active provenance node */}
{currentNode && provenance && provenance.nodes[currentNode] && (
<rect fill={colorMap.get(provenance.nodes[currentNode].label) || '#9498a0'} x={xScale(provenance.nodes[currentNode].createdOn) - RECT_WIDTH / 2} y={height / 2 - RECT_HEIGHT / 2} width={RECT_WIDTH} height={RECT_HEIGHT} />
<rect fill={getNodeColor(provenance.nodes[currentNode])} x={xScale(provenance.nodes[currentNode].createdOn) - RECT_WIDTH / 2} y={height / 2 - RECT_HEIGHT / 2} width={RECT_WIDTH} height={RECT_HEIGHT} />
)}
<Affix position={{ bottom: 10, left: 10 }}>
{/* <Popover width={200} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button>Show Legend</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack>
{
Array.from(colorMap.keys()).map((key) => (
<Group key={key}>
<ColorSwatch color={colorMap.get(key)} />
<span>{key}</span>
</Group>
))
}
</Stack>
</Popover.Dropdown>
</Popover> */}
</Affix>
</g>
);
}
1 change: 0 additions & 1 deletion src/components/audioAnalysis/TaskProvenanceTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export function TaskProvenanceTimeline({
if (graph) {
return (
<TaskProvenanceNodes
answer={answers[trialName]}
key={name + provenanceArea}
height={height}
currentNode={currentNode}
Expand Down
Loading
Loading