diff --git a/apps/azure/src/components/editor/InvestigationWorkspace.tsx b/apps/azure/src/components/editor/InvestigationWorkspace.tsx index 3a2889969..0a03d78f9 100644 --- a/apps/azure/src/components/editor/InvestigationWorkspace.tsx +++ b/apps/azure/src/components/editor/InvestigationWorkspace.tsx @@ -41,6 +41,8 @@ import { computeInteractionEffects, } from '@variscout/core'; import { detectEvidenceClusters } from '@variscout/core/findings'; +import type { ColumnTypeMap } from '@variscout/core/findings'; +import { detectColumns } from '@variscout/core/parser'; import { detectInvestigationPhase } from '@variscout/core/ai'; import { resolveMode, getStrategy } from '@variscout/core/strategy'; import { resolveCpkTarget } from '@variscout/core/capability'; @@ -179,6 +181,13 @@ export const InvestigationWorkspace: React.FC = ({ () => (rawData.length > 0 ? Object.keys(rawData[0]) : undefined), [rawData] ); + const columnTypes = useMemo(() => { + if (rawData.length === 0) return {}; + const det = detectColumns(rawData); + const map: ColumnTypeMap = {}; + for (const c of det.columnAnalysis) map[c.name] = c.type; + return map; + }, [rawData]); const highlightedFindingId = useFindingsStore(s => s.highlightedFindingId); const causalLinks = useInvestigationStore(s => s.causalLinks); @@ -812,6 +821,9 @@ export const InvestigationWorkspace: React.FC = ({ problemCpk={0} eventsPerWeek={0} activeColumns={wallActiveColumns} + rows={rawData} + columnTypes={columnTypes} + outcomeColumn={outcome} zoom={wallZoom} pan={wallPan} groupByTributary={Boolean(processMap && wallGroupByTributary)} diff --git a/apps/pwa/src/components/views/InvestigationView.tsx b/apps/pwa/src/components/views/InvestigationView.tsx index f6e62cc67..1c5785a98 100644 --- a/apps/pwa/src/components/views/InvestigationView.tsx +++ b/apps/pwa/src/components/views/InvestigationView.tsx @@ -31,6 +31,8 @@ import type { FindingStatus, Question } from '@variscout/core'; import { detectInvestigationPhase } from '@variscout/core/ai'; import { getStrategy } from '@variscout/core/strategy'; import type { ResolvedMode } from '@variscout/core/strategy'; +import { detectColumns } from '@variscout/core/parser'; +import type { ColumnTypeMap } from '@variscout/core/findings'; import type { DrillStep } from '@variscout/hooks'; import { GripVertical } from 'lucide-react'; import { useWallLayoutStore, useProjectStore, useInvestigationStore } from '@variscout/stores'; @@ -95,12 +97,20 @@ const InvestigationView: React.FC = ({ const setWallGroupByTributary = useWallLayoutStore(s => s.setGroupByTributary); const processMap = useProjectStore(s => s.processContext?.processMap); const rawData = useProjectStore(s => s.rawData); + const outcome = useProjectStore(s => s.outcome); // Undefined when no rows are loaded so WallCanvas keeps the missing-column // badge suppressed (rather than flagging every hub against an empty set). const wallActiveColumns = useMemo( () => (rawData.length > 0 ? Object.keys(rawData[0]) : undefined), [rawData] ); + const columnTypes = useMemo(() => { + if (rawData.length === 0) return {}; + const det = detectColumns(rawData); + const map: ColumnTypeMap = {}; + for (const c of det.columnAnalysis) map[c.name] = c.type; + return map; + }, [rawData]); const hubs = useInvestigationStore(s => s.hypotheses); const wallFindings = useInvestigationStore(s => s.findings); const wallQuestions = useInvestigationStore(s => s.questions); @@ -314,6 +324,9 @@ const InvestigationView: React.FC = ({ problemCpk={0} eventsPerWeek={0} activeColumns={wallActiveColumns} + rows={rawData} + columnTypes={columnTypes} + outcomeColumn={outcome} zoom={wallZoom} pan={wallPan} groupByTributary={Boolean(processMap && wallGroupByTributary)} diff --git a/packages/core/src/findings/__tests__/miniChart.test.ts b/packages/core/src/findings/__tests__/miniChart.test.ts new file mode 100644 index 000000000..618ed3940 --- /dev/null +++ b/packages/core/src/findings/__tests__/miniChart.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { deriveMiniChartConfig } from '../miniChart'; +import type { Hypothesis } from '../types'; + +const hub = (condition: Hypothesis['condition']): Hypothesis => + ({ + id: 'h1', + name: 'test', + synthesis: '', + questionIds: [], + findingIds: [], + status: 'proposed', + investigationId: 'inv-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + condition, + }) as Hypothesis; + +describe('deriveMiniChartConfig', () => { + it('returns i-chart for numeric leaf factor', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }), + { TEMP: 'numeric' }, + 'thickness' + ); + expect(cfg).toEqual({ kind: 'i-chart', factor: 'TEMP', outcome: 'thickness' }); + }); + + it('returns i-chart for date leaf factor (time-ordered)', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'shift_start', op: 'gte', value: 0 }), + { shift_start: 'date' }, + 'thickness' + ); + expect(cfg).toEqual({ kind: 'i-chart', factor: 'shift_start', outcome: 'thickness' }); + }); + + it('returns boxplot for categorical leaf factor when outcome present', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }), + { SUPPLIER: 'categorical' }, + 'thickness' + ); + expect(cfg).toEqual({ kind: 'boxplot', factor: 'SUPPLIER', outcome: 'thickness' }); + }); + + it('returns placeholder for categorical leaf when outcome missing', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }), + { SUPPLIER: 'categorical' }, + undefined + ); + expect(cfg).toEqual({ kind: 'placeholder', factor: 'SUPPLIER', reason: 'no-outcome' }); + }); + + it('returns placeholder for text factor', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'NOTES', op: 'eq', value: 'x' }), + { NOTES: 'text' }, + 'thickness' + ); + expect(cfg).toEqual({ kind: 'placeholder', factor: 'NOTES', reason: 'unsupported-type' }); + }); + + it('returns placeholder when condition is undefined', () => { + const cfg = deriveMiniChartConfig(hub(undefined), {}, 'thickness'); + expect(cfg).toEqual({ kind: 'placeholder', reason: 'no-condition' }); + }); + + it('returns placeholder reason no-factor for empty AND children', () => { + const cfg = deriveMiniChartConfig(hub({ kind: 'and', children: [] }), {}, 'thickness'); + expect(cfg).toEqual({ kind: 'placeholder', reason: 'no-factor' }); + }); + + it('descends into AND branch and uses first leaf column', () => { + const cfg = deriveMiniChartConfig( + hub({ + kind: 'and', + children: [ + { kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }, + { kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }, + ], + }), + { SUPPLIER: 'categorical', TEMP: 'numeric' }, + 'thickness' + ); + expect(cfg.factor).toBe('SUPPLIER'); + expect(cfg.kind).toBe('boxplot'); + }); + + it('returns placeholder when factor column is unknown', () => { + const cfg = deriveMiniChartConfig( + hub({ kind: 'leaf', column: 'GHOST', op: 'eq', value: 'x' }), + {}, + 'thickness' + ); + expect(cfg).toEqual({ kind: 'placeholder', factor: 'GHOST', reason: 'unknown-column' }); + }); +}); diff --git a/packages/core/src/findings/index.ts b/packages/core/src/findings/index.ts index d3d9fe830..3d255d5f7 100644 --- a/packages/core/src/findings/index.ts +++ b/packages/core/src/findings/index.ts @@ -53,3 +53,10 @@ export { computeFindingWindowDrift } from './drift'; export type { DriftResult } from './drift'; // WindowContext is already re-exported via `export * from './types'` above. export { evidenceTypesForHypothesis, hasUnresolvedDisconfirmation } from './hypothesisEvidence'; +export { + deriveMiniChartConfig, + type MiniChartConfig, + type MiniChartKind, + type MiniChartPlaceholderReason, + type ColumnTypeMap, +} from './miniChart'; diff --git a/packages/core/src/findings/miniChart.ts b/packages/core/src/findings/miniChart.ts new file mode 100644 index 000000000..b2ab489d7 --- /dev/null +++ b/packages/core/src/findings/miniChart.ts @@ -0,0 +1,111 @@ +/** + * Pure helper: maps a Hypothesis.condition + column-type map + outcome column + * to a discriminated union describing which mini-chart to render inside a + * HypothesisCard on the Investigation Wall. + * + * This module has no UI imports and must stay pure TypeScript. + */ + +import type { Hypothesis } from './types'; +import type { HypothesisCondition } from './hypothesisCondition'; +import type { ColumnAnalysis } from '../parser/types'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type MiniChartKind = 'i-chart' | 'boxplot' | 'placeholder'; + +export type MiniChartPlaceholderReason = + | 'no-condition' + | 'no-factor' + | 'unknown-column' + | 'unsupported-type' + | 'no-outcome'; + +export type MiniChartConfig = + | { kind: 'i-chart'; factor: string; outcome?: string } + | { kind: 'boxplot'; factor: string; outcome: string } + | { kind: 'placeholder'; factor?: string; reason: MiniChartPlaceholderReason }; + +/** Lookup table from column name → parser-detected column type. */ +export type ColumnTypeMap = Record; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Depth-first search for the first leaf column in a condition tree. + * Handles all four condition kinds: leaf, and, or, not. + */ +function firstLeafColumn(c: HypothesisCondition): string | undefined { + switch (c.kind) { + case 'leaf': + return c.column; + case 'and': + case 'or': + for (const child of c.children) { + const col = firstLeafColumn(child); + if (col !== undefined) return col; + } + return undefined; + case 'not': + return firstLeafColumn(c.child); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Derive the mini-chart configuration for a hypothesis card. + * + * Decision rules (in priority order): + * 1. No condition → `placeholder` with reason `no-condition`. + * 2. No leaf column found in tree → `placeholder` with reason `no-factor`. + * 3. Column not in `columnTypes` map → `placeholder` with reason `unknown-column`. + * 4. `numeric` | `date` column → `i-chart` (time-ordered individual values). + * 5. `categorical` column + outcome present → `boxplot`. + * 6. `categorical` column + no outcome → `placeholder` with reason `no-outcome`. + * 7. Any other type (`text`, future additions) → `placeholder` with reason `unsupported-type`. + * + * @param hypothesis The hypothesis whose `condition` field drives chart selection. + * @param columnTypes Map from column name → parser-detected type. + * @param outcome The outcome column name (e.g. "thickness"). Pass `undefined` + * or `null` when no outcome has been detected for the dataset. + */ +export function deriveMiniChartConfig( + hypothesis: Hypothesis, + columnTypes: ColumnTypeMap, + outcome: string | undefined | null +): MiniChartConfig { + if (!hypothesis.condition) { + return { kind: 'placeholder', reason: 'no-condition' }; + } + + const factor = firstLeafColumn(hypothesis.condition); + if (factor === undefined) { + return { kind: 'placeholder', reason: 'no-factor' }; + } + + const colType = columnTypes[factor]; + if (colType === undefined) { + return { kind: 'placeholder', factor, reason: 'unknown-column' }; + } + + if (colType === 'numeric' || colType === 'date') { + return { kind: 'i-chart', factor, outcome: outcome ?? undefined }; + } + + if (colType === 'categorical') { + if (outcome == null) { + return { kind: 'placeholder', factor, reason: 'no-outcome' }; + } + return { kind: 'boxplot', factor, outcome }; + } + + // Covers 'text' and any future ColumnAnalysis types. + return { kind: 'placeholder', factor, reason: 'unsupported-type' }; +} diff --git a/packages/hooks/src/__tests__/useMiniChartData.test.ts b/packages/hooks/src/__tests__/useMiniChartData.test.ts new file mode 100644 index 000000000..be5fcdc35 --- /dev/null +++ b/packages/hooks/src/__tests__/useMiniChartData.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useMiniChartData } from '../useMiniChartData'; +import type { Hypothesis } from '@variscout/core/findings'; + +const hub = (condition: Hypothesis['condition']): Hypothesis => + ({ + id: 'h1', + name: '', + synthesis: '', + questionIds: [], + findingIds: [], + status: 'proposed', + investigationId: 'inv-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + condition, + }) as Hypothesis; + +describe('useMiniChartData', () => { + it('returns ichart values for numeric factor', () => { + const rows = [{ TEMP: 90 }, { TEMP: 95 }, { TEMP: 100 }]; + const { result } = renderHook(() => + useMiniChartData( + hub({ kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }), + rows, + { TEMP: 'numeric' }, + 'thickness' + ) + ); + expect(result.current.kind).toBe('i-chart'); + expect(result.current.values).toEqual([90, 95, 100]); + }); + + it('returns boxplot groups for categorical factor with outcome', () => { + const rows = [ + { SUPPLIER: 'A', thickness: 1.0 }, + { SUPPLIER: 'A', thickness: 1.1 }, + { SUPPLIER: 'B', thickness: 1.5 }, + ]; + const { result } = renderHook(() => + useMiniChartData( + hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }), + rows, + { SUPPLIER: 'categorical', thickness: 'numeric' }, + 'thickness' + ) + ); + expect(result.current.kind).toBe('boxplot'); + expect(result.current.groups).toHaveLength(2); + const a = result.current.groups!.find(g => g.category === 'A'); + expect(a!.values).toEqual([1.0, 1.1]); + }); + + it('returns placeholder for categorical factor without outcome', () => { + const { result } = renderHook(() => + useMiniChartData( + hub({ kind: 'leaf', column: 'SUPPLIER', op: 'eq', value: 'B' }), + [], + { SUPPLIER: 'categorical' }, + undefined + ) + ); + expect(result.current.kind).toBe('placeholder'); + expect(result.current.reason).toBe('no-outcome'); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 86d866b25..e39f582b2 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -637,3 +637,6 @@ export { useSessionCanvasFilters, type UseSessionCanvasFiltersResult, } from './useSessionCanvasFilters'; + +// Mini-chart data derivation (Wall Detective-pack) +export { useMiniChartData, type MiniChartData } from './useMiniChartData'; diff --git a/packages/hooks/src/useMiniChartData.ts b/packages/hooks/src/useMiniChartData.ts new file mode 100644 index 000000000..f15290f55 --- /dev/null +++ b/packages/hooks/src/useMiniChartData.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { + deriveMiniChartConfig, + type MiniChartConfig, + type ColumnTypeMap, +} from '@variscout/core/findings'; +import type { Hypothesis } from '@variscout/core/findings'; + +export interface MiniChartData { + kind: MiniChartConfig['kind']; + factor?: string; + outcome?: string; + reason?: string; + values?: number[]; + groups?: Array<{ category: string; values: number[] }>; +} + +export function useMiniChartData( + hypothesis: Hypothesis, + rows: ReadonlyArray>, + columnTypes: ColumnTypeMap, + outcome: string | undefined | null +): MiniChartData { + return useMemo(() => { + const cfg = deriveMiniChartConfig(hypothesis, columnTypes, outcome ?? undefined); + if (cfg.kind === 'placeholder') { + return { kind: 'placeholder', factor: cfg.factor, reason: cfg.reason }; + } + if (cfg.kind === 'i-chart') { + const values: number[] = []; + for (const row of rows) { + const v = Number(row[cfg.factor]); + if (Number.isFinite(v)) values.push(v); + } + return { kind: 'i-chart', factor: cfg.factor, outcome: cfg.outcome, values }; + } + // boxplot + const map = new Map(); + for (const row of rows) { + const cat = row[cfg.factor]; + const yVal = Number(row[cfg.outcome]); + if (cat == null || !Number.isFinite(yVal)) continue; + const key = String(cat); + const arr = map.get(key); + if (arr) arr.push(yVal); + else map.set(key, [yVal]); + } + const groups = Array.from(map.entries()) + .map(([category, values]) => ({ category, values })) + .sort((a, b) => a.category.localeCompare(b.category)); + return { kind: 'boxplot', factor: cfg.factor, outcome: cfg.outcome, groups }; + }, [hypothesis, rows, columnTypes, outcome]); +} diff --git a/packages/ui/src/components/InvestigationWall/HypothesisCard.tsx b/packages/ui/src/components/InvestigationWall/HypothesisCard.tsx index 40079bcdd..bea3abab3 100644 --- a/packages/ui/src/components/InvestigationWall/HypothesisCard.tsx +++ b/packages/ui/src/components/InvestigationWall/HypothesisCard.tsx @@ -16,11 +16,15 @@ import type { Hypothesis, HypothesisStatus, } from '@variscout/core'; +import type { ColumnTypeMap } from '@variscout/core/findings'; import { formatMessage, getMessage } from '@variscout/core/i18n'; import { chartColors } from '@variscout/charts'; +import { useMiniChartData } from '@variscout/hooks'; import { useWallLocale } from './hooks/useWallLocale'; import { TagChip } from './TagChip'; import { OneStepAwayBadge } from './OneStepAwayBadge'; +import { MiniIChart } from './MiniIChart'; +import { MiniBoxplot } from './MiniBoxplot'; export interface HypothesisCardProps { hub: Hypothesis; @@ -44,25 +48,36 @@ export interface HypothesisCardProps { * Left `undefined` → full card always (backward compatibility). */ zoomScale?: number; + /** Dataset rows used to populate the mini-chart slot (full LOD only). */ + rows?: ReadonlyArray>; + /** Column-type map from the active dataset parser output. */ + columnTypes?: ColumnTypeMap; + /** Outcome column name for boxplot charts; omit when no outcome is set. */ + outcomeColumn?: string | null; onSelect?: (hubId: string) => void; onContextMenu?: (hubId: string, event: React.MouseEvent) => void; } const CARD_W = 280; -const CARD_H = 228; +const CARD_H = 288; const BODY_TOP = 64; -const TAG_ROW_Y = 94; +const CHART_SLOT_X = 16; +const CHART_SLOT_Y = BODY_TOP; // 64 +const CHART_SLOT_W = CARD_W - 32; // 248 +const CHART_SLOT_H = 80; +const POST_CHART_Y = CHART_SLOT_Y + CHART_SLOT_H + 8; // 152 — top of remaining body content +const TAG_ROW_Y = POST_CHART_Y; const TAG_ROW_H = 24; -const TAGGED_READINESS_Y = 126; -const TAGGED_CLUE_COUNT_Y = 146; -const DEFAULT_READINESS_Y = 108; -const DEFAULT_CLUE_COUNT_Y = 130; +const TAGGED_READINESS_Y = TAG_ROW_Y + TAG_ROW_H + 8; // 184 +const TAGGED_CLUE_COUNT_Y = TAGGED_READINESS_Y + 18; // 202 +const DEFAULT_READINESS_Y = POST_CHART_Y + 4; // 156 (no tags) +const DEFAULT_CLUE_COUNT_Y = DEFAULT_READINESS_Y + 18; // 174 /** * Y position of the OneStepAwayBadge when it replaces the openChecksLabel row. - * Badge spans y=168–188; nextMove glyphs at y=192–204 → 4px clearance. - * CARD_H - 60 = 228 - 60 = 168. + * Badge spans y=228–248; nextMove glyphs at y=252–264 → 4px clearance. + * CARD_H - 60 = 288 - 60 = 228. */ -const ONE_STEP_AWAY_Y = CARD_H - 60; // 168 +const ONE_STEP_AWAY_Y = CARD_H - 60; // 228 const STATUS_KEY: Record = { proposed: 'wall.status.proposed', @@ -81,6 +96,40 @@ const STATUS_STROKE: Record = { 'needs-disconfirmation': chartColors.warning, // amber — needs disconfirmation check }; +interface ChartSlotProps { + hub: Hypothesis; + rows: ReadonlyArray>; + columnTypes: ColumnTypeMap; + outcomeColumn: string | null; +} + +function ChartSlot({ hub, rows, columnTypes, outcomeColumn }: ChartSlotProps) { + const chart = useMiniChartData(hub, rows, columnTypes, outcomeColumn); + + return ( + + {chart.kind === 'i-chart' && chart.values && chart.values.length > 0 ? ( + + ) : chart.kind === 'boxplot' && chart.groups && chart.groups.length > 0 ? ( + + ) : ( +
+ {chart.reason === 'no-outcome' + ? 'Set outcome to enable chart' + : chart.reason === 'unknown-column' + ? `Column "${chart.factor}" not found` + : chart.reason === 'unsupported-type' + ? 'Chart unavailable for this factor' + : '+ Add condition'} +
+ )} +
+ ); +} + export const HypothesisCard: React.FC = ({ hub, branch, @@ -90,6 +139,9 @@ export const HypothesisCard: React.FC = ({ hasGap, missingColumn, zoomScale, + rows, + columnTypes, + outcomeColumn, onSelect, onContextMenu, }) => { @@ -197,13 +249,18 @@ export const HypothesisCard: React.FC = ({ x={16} y={BODY_TOP} width={CARD_W - 32} - height={96} + height={CARD_H - BODY_TOP - 16} rx={4} className="fill-surface" /> - - Suspected mechanism - + {rows && columnTypes ? ( + + ) : null} {themeTags.length > 0 && (
= ({ {isOneStepAway ? ( /* Badge replaces openChecksLabel for needs-disconfirmation. - y=168, height=20 → spans 168–188; nextMove glyphs at 192–204 → 4px clear. */ + y=228, height=20 → spans 228–248; nextMove glyphs at 252–264 → 4px clear. */ number { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// FNV-1a 32-bit hash for string seeding +function hashStr(s: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { + h = Math.imul(h ^ s.charCodeAt(i), 16777619); + } + return h >>> 0; +} + +function quantile(sorted: number[], q: number): number { + if (sorted.length === 0) return NaN; + if (sorted.length === 1) return sorted[0]; + const idx = (sorted.length - 1) * q; + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo); +} + +export function MiniBoxplot({ groups, width, height }: MiniBoxplotProps) { + const theme = useChartTheme(); + if (groups.length === 0) return null; + + const allValues = groups.flatMap(g => g.values).filter(v => Number.isFinite(v)); + if (allValues.length === 0) return null; + + const yMin = Math.min(...allValues); + const yMax = Math.max(...allValues); + const yRange = yMax - yMin || 1; + const yFor = (v: number) => height - ((v - yMin) / yRange) * height; + + const groupW = width / groups.length; + const boxW = Math.min(groupW * 0.6, 24); + + return ( + + {groups.map((g, i) => { + const cx = i * groupW + groupW / 2; + const finite = g.values + .filter(v => Number.isFinite(v)) + .slice() + .sort((a, b) => a - b); + + if (finite.length < MIN_BOXPLOT_VALUES) { + // Deterministic jitter: PRNG seeded by category hash — never Math.random + const rng = mulberry32(hashStr(g.category)); + return ( + + {finite.map((v, j) => { + const jitter = (rng() - 0.5) * boxW * 0.5; + return ( + + ); + })} + + ); + } + + const q1 = quantile(finite, 0.25); + const med = quantile(finite, 0.5); + const q3 = quantile(finite, 0.75); + const lo = finite[0]; + const hi = finite[finite.length - 1]; + + return ( + + {/* whisker */} + + {/* IQR box */} + + {/* median line */} + + + ); + })} + + ); +} diff --git a/packages/ui/src/components/InvestigationWall/MiniIChart.tsx b/packages/ui/src/components/InvestigationWall/MiniIChart.tsx new file mode 100644 index 000000000..8134adddb --- /dev/null +++ b/packages/ui/src/components/InvestigationWall/MiniIChart.tsx @@ -0,0 +1,62 @@ +import { useChartTheme } from '@variscout/charts'; + +export interface MiniIChartProps { + values: number[]; + width: number; + height: number; +} + +export function MiniIChart({ values, width, height }: MiniIChartProps) { + const theme = useChartTheme(); + if (values.length === 0) return null; + + const finiteValues = values.filter(v => Number.isFinite(v)); + if (finiteValues.length === 0) return null; + + const min = Math.min(...finiteValues); + const max = Math.max(...finiteValues); + const range = max - min || 1; + const mean = finiteValues.reduce((s, v) => s + v, 0) / finiteValues.length; + const stepX = finiteValues.length > 1 ? width / (finiteValues.length - 1) : width / 2; + const yFor = (v: number) => height - ((v - min) / range) * height; + + const path = finiteValues + .map((v, i) => { + const x = Math.round((finiteValues.length > 1 ? i * stepX : width / 2) * 10) / 10; + const y = Math.round(yFor(v) * 10) / 10; + return `${i === 0 ? 'M' : 'L'} ${x} ${y}`; + }) + .join(' '); + + const meanY = yFor(mean); + + return ( + + + + + ); +} diff --git a/packages/ui/src/components/InvestigationWall/WallCanvas.tsx b/packages/ui/src/components/InvestigationWall/WallCanvas.tsx index 1af014e88..2fd8a2b9d 100644 --- a/packages/ui/src/components/InvestigationWall/WallCanvas.tsx +++ b/packages/ui/src/components/InvestigationWall/WallCanvas.tsx @@ -19,6 +19,7 @@ import type { GateNode, GatePath, } from '@variscout/core'; +import type { ColumnTypeMap } from '@variscout/core/findings'; import { conditionHasMissingColumn, projectMechanismBranch } from '@variscout/core'; import { getMessage } from '@variscout/core/i18n'; import { ProblemConditionCard } from './ProblemConditionCard'; @@ -84,6 +85,12 @@ export interface WallCanvasProps { * `wallLayoutStore.groupByTributary`. */ groupByTributary?: boolean; + /** Dataset rows forwarded to each HypothesisCard to populate the mini-chart slot. */ + rows?: ReadonlyArray>; + /** Column type map (from `detectColumns`) forwarded to each HypothesisCard. */ + columnTypes?: ColumnTypeMap; + /** Investigation-level outcome column forwarded to each HypothesisCard for boxplot Y-axis. */ + outcomeColumn?: string | null; /** * Render mode. * - `'destination'` (default): full destination-view chrome including @@ -142,6 +149,9 @@ export const WallCanvas: React.FC = ({ pan = { x: 0, y: 0 }, groupByTributary = false, mode = 'destination', + rows, + columnTypes, + outcomeColumn, }) => { const locale = useWallLocale(); const columnSet = useMemo( @@ -259,6 +269,9 @@ export const WallCanvas: React.FC = ({ missingColumn: columnSet ? conditionHasMissingColumn(hub.condition, columnSet) : false, zoomScale: zoom !== 1 ? zoom : undefined, onSelect: onSelectHub, + rows, + columnTypes, + outcomeColumn, }; return dndEnabled ? ( @@ -318,7 +331,7 @@ export const WallCanvas: React.FC = ({ x={bandX0 + GROUP_PAD_X / 2} y={hubY - 40} width={bandWidth - GROUP_PAD_X} - height={260} + height={348} rx={12} className="fill-transparent stroke-edge" strokeDasharray="4 4" diff --git a/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.miniChart.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.miniChart.test.tsx new file mode 100644 index 000000000..e7414662c --- /dev/null +++ b/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.miniChart.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { vi, describe, it, expect } from 'vitest'; + +vi.mock('@variscout/charts', async () => { + const actual = await vi.importActual('@variscout/charts'); + return { + ...actual, + useChartTheme: () => ({ + colors: { + mean: '#3b82f6', + control: '#06b6d4', + warning: '#f59e0b', + pass: '#22c55e', + fail: '#ef4444', + violation: '#f97316', + }, + chrome: { labelMuted: '#94a3b8' }, + }), + }; +}); + +import { render, screen } from '@testing-library/react'; +import { HypothesisCard } from '../HypothesisCard'; +import type { Hypothesis } from '@variscout/core/findings'; + +const wrapInSvg = (children: React.ReactNode) => ( + + {children} + +); + +const baseHub: Hypothesis = { + id: 'h1', + name: 'Nozzle hot on night shift', + synthesis: '', + questionIds: [], + findingIds: [], + status: 'evidenced', + investigationId: 'inv-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + condition: { kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }, +}; + +describe('HypothesisCard mini-chart slot', () => { + it('renders MiniIChart for numeric factor at full LOD', () => { + render( + wrapInSvg( + + ) + ); + expect(screen.getByTestId('mini-i-chart-path')).toBeInTheDocument(); + }); + + it('renders MiniBoxplot for categorical factor when outcome supplied', () => { + const hub = { + ...baseHub, + condition: { kind: 'leaf' as const, column: 'SUPPLIER', op: 'eq' as const, value: 'B' }, + }; + const rows = [ + ...Array.from({ length: 7 }, () => ({ SUPPLIER: 'A', thickness: 1.0 })), + ...Array.from({ length: 7 }, () => ({ SUPPLIER: 'B', thickness: 1.5 })), + ]; + render( + wrapInSvg( + + ) + ); + expect(screen.getByTestId('mini-boxplot-box-A')).toBeInTheDocument(); + expect(screen.getByTestId('mini-boxplot-box-B')).toBeInTheDocument(); + }); + + it('renders no chart at medium LOD (zoomScale low)', () => { + render( + wrapInSvg( + + ) + ); + expect(screen.queryByTestId('mini-i-chart-path')).toBeNull(); + }); + + it('renders placeholder text for categorical factor without outcome', () => { + const hub = { + ...baseHub, + condition: { kind: 'leaf' as const, column: 'SUPPLIER', op: 'eq' as const, value: 'B' }, + }; + render( + wrapInSvg( + + ) + ); + expect(screen.getByTestId('mini-chart-placeholder')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.test.tsx index 29be09bae..1b034d45c 100644 --- a/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.test.tsx +++ b/packages/ui/src/components/InvestigationWall/__tests__/HypothesisCard.test.tsx @@ -205,7 +205,6 @@ describe('HypothesisCard', () => { ); expect(screen.getByText(/Mechanism Branch/i)).toBeInTheDocument(); - expect(screen.getByText(/Suspected mechanism/i)).toBeInTheDocument(); expect(screen.getByText(/2 supporting clues/i)).toBeInTheDocument(); expect(screen.getByText(/1 counter-clue/i)).toBeInTheDocument(); expect(screen.getByText(/1 open check/i)).toBeInTheDocument(); diff --git a/packages/ui/src/components/InvestigationWall/__tests__/MiniBoxplot.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/MiniBoxplot.test.tsx new file mode 100644 index 000000000..5d09d814d --- /dev/null +++ b/packages/ui/src/components/InvestigationWall/__tests__/MiniBoxplot.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@variscout/charts', () => ({ + useChartTheme: () => ({ + isDark: true, + chrome: { + labelMuted: '#64748b', + }, + colors: { + control: '#10b981', + }, + }), +})); + +import { render, screen } from '@testing-library/react'; +import { MiniBoxplot } from '../MiniBoxplot'; + +describe('MiniBoxplot', () => { + it('renders one group rect per category (≥7 values)', () => { + render( + + ); + expect(screen.getAllByTestId(/mini-boxplot-box-/)).toHaveLength(3); + }); + + it('falls back to dots for groups below MIN_BOXPLOT_VALUES (7)', () => { + render(); + expect(screen.queryByTestId('mini-boxplot-box-A')).toBeNull(); + expect(screen.getByTestId('mini-boxplot-dots-A')).toBeInTheDocument(); + }); + + it('renders nothing when groups is empty', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('produces deterministic dot positions across re-renders (no Math.random)', () => { + const props = { + groups: [{ category: 'A', values: [1, 2, 3] }], + width: 248, + height: 80, + }; + const first = render(); + const dotsA = first.container.innerHTML; + first.unmount(); + const second = render(); + expect(second.container.innerHTML).toBe(dotsA); + }); +}); diff --git a/packages/ui/src/components/InvestigationWall/__tests__/MiniIChart.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/MiniIChart.test.tsx new file mode 100644 index 000000000..c508042b2 --- /dev/null +++ b/packages/ui/src/components/InvestigationWall/__tests__/MiniIChart.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@variscout/charts', () => ({ + useChartTheme: () => ({ + isDark: true, + chrome: { + labelMuted: '#64748b', + }, + colors: { + mean: '#3b82f6', + }, + }), +})); + +import { render, screen } from '@testing-library/react'; +import { MiniIChart } from '../MiniIChart'; + +describe('MiniIChart', () => { + it('renders a line path for the values', () => { + render(); + const path = screen.getByTestId('mini-i-chart-path'); + expect(path).toBeInTheDocument(); + expect(path.getAttribute('d')).toMatch(/^M /); + }); + + it('renders nothing when values is empty', () => { + const { container } = render(); + expect(container.querySelector('[data-testid="mini-i-chart-path"]')).toBeNull(); + }); + + it('handles a single value (degenerate range) without crashing', () => { + render(); + expect(screen.getByTestId('mini-i-chart-path')).toBeInTheDocument(); + }); + + it('renders a centerline at the mean', () => { + render(); + expect(screen.getByTestId('mini-i-chart-mean')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.miniChart.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.miniChart.test.tsx new file mode 100644 index 000000000..2e170e173 --- /dev/null +++ b/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.miniChart.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@variscout/charts', async () => { + const actual = await vi.importActual('@variscout/charts'); + return { + ...actual, + useChartTheme: () => ({ + colors: { + mean: '#3b82f6', + control: '#06b6d4', + warning: '#f59e0b', + pass: '#22c55e', + fail: '#ef4444', + violation: '#f97316', + }, + chrome: { labelMuted: '#94a3b8' }, + }), + }; +}); + +import { WallCanvas } from '../WallCanvas'; +import type { Hypothesis } from '@variscout/core/findings'; + +const hub: Hypothesis = { + id: 'h1', + name: 'Nozzle hot', + synthesis: '', + questionIds: [], + findingIds: [], + status: 'evidenced', + investigationId: 'inv-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + condition: { kind: 'leaf', column: 'TEMP', op: 'gt', value: 95 }, +}; + +describe('WallCanvas mini-chart prop wiring', () => { + it('passes rows + columnTypes through to HypothesisCard so charts render', () => { + render( + + ); + expect(screen.getByTestId('mini-i-chart-path')).toBeInTheDocument(); + }); +});