import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types' export interface BoardPoint { x: number; y: number } function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) { const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100 const distance = Math.hypot(to.x - from.x, to.y - from.y) const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28)) const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag } const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag } return { c1, c2 } } function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) { const inverse = 1 - t return { x: inverse ** 3 * from.x + 3 * inverse ** 2 * t * c1.x + 3 * inverse * t ** 2 * c2.x + t ** 3 * to.x, y: inverse ** 3 * from.y + 3 * inverse ** 2 * t * c1.y + 3 * inverse * t ** 2 * c2.y + t ** 3 * to.y, } } function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) { const inverse = 1 - t return { x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x), y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y), } } export function threadTagLateralLimit(tightness = 65) { const normalized = Math.max(0, Math.min(100, Number(tightness) || 0)) return Math.round(10 + (100 - normalized) * .6) } export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) { const { c1, c2 } = threadControls(from, to, tightness) const position = Math.max(5, Math.min(95, Number(positionPercent) || 50)) const t = position / 100 const point = cubicPoint(from, c1, c2, to, t) const tangent = cubicTangent(from, c1, c2, to, t) const length = Math.hypot(tangent.x, tangent.y) || 1 const normal = { x: -tangent.y / length, y: tangent.x / length } const maxLateralOffset = threadTagLateralLimit(tightness) const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0)) return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset } } export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) { const { c1, c2 } = threadControls(from, to, tightness) let bestT = .5 let bestDistance = Number.POSITIVE_INFINITY for (let index = 5; index <= 95; index += 1) { const t = index / 100 const point = cubicPoint(from, c1, c2, to, t) const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2 if (distance < bestDistance) { bestDistance = distance; bestT = t } } const point = cubicPoint(from, c1, c2, to, bestT) const tangent = cubicTangent(from, c1, c2, to, bestT) const length = Math.hypot(tangent.x, tangent.y) || 1 const normal = { x: -tangent.y / length, y: tangent.x / length } const maxLateralOffset = threadTagLateralLimit(tightness) const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y)) return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset } } export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) { const { c1, c2 } = threadControls(from, to, tightness) const midpoint = cubicPoint(from, c1, c2, to, .5) return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint } } export const MIN_BOARD_ZOOM = 0.45 export const MAX_BOARD_ZOOM = 1.5 export function clampBoardZoom(zoom: number) { return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom)) } export function zoomFromWheel(currentZoom: number, deltaY: number) { return clampBoardZoom(currentZoom - deltaY * 0.0015) } export function zoomFromPinch(currentZoom: number, previousDistance: number, nextDistance: number) { if (!Number.isFinite(previousDistance) || !Number.isFinite(nextDistance) || previousDistance <= 0 || nextDistance <= 0) return currentZoom return clampBoardZoom(currentZoom * nextDistance / previousDistance) } export function zoomViewportAt(viewport: Viewport, nextZoom: number, screenAnchor: BoardPoint): Viewport { if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive') const zoom = clampBoardZoom(nextZoom) const boardPoint = { x: (screenAnchor.x - viewport.x) / viewport.zoom, y: (screenAnchor.y - viewport.y) / viewport.zoom } return { x: screenAnchor.x - boardPoint.x * zoom, y: screenAnchor.y - boardPoint.y * zoom, zoom } } export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) { if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive') return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom } } export function nextOpenBoardPosition( evidence: Pick[], preferred: { x: number; y: number }, size: { width: number; height?: number }, bounds = { width: 2400, height: 1500 }, ) { const height = size.height || 160 const gap = 28 const overlaps = (x: number, y: number) => evidence.some(item => x < item.x + item.width + gap && x + size.width + gap > item.x && y < item.y + item.height + gap && y + height + gap > item.y) const xStep = size.width + 46 const yStep = height + 46 for (let row = 0; row < 7; row += 1) { for (const column of [0, 1, -1, 2, -2, 3, -3]) { const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep)) const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep)) if (!overlaps(x, y)) return { x, y } } } return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) } } export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport { return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y } } export function dateValue(date: string) { const parsed = Date.parse(date) return Number.isFinite(parsed) ? parsed : 0 } export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) { if (configured) { const start = Date.parse(`${configured.start}T00:00:00.000Z`) const end = Date.parse(`${configured.end}T23:59:59.999Z`) if (Number.isFinite(start) && Number.isFinite(end) && end > start) { return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end } } } const years = dates .map(date => Number(date.slice(0, 4))) .filter(year => Number.isFinite(year) && year >= 1 && year <= 9999) let startYear = years.length ? Math.min(...years) : fallbackYear - 2 let endYear = years.length ? Math.max(...years) : startYear + 4 if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear) startYear -= Math.floor(missing / 2) endYear += Math.ceil(missing / 2) } return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) } } export function timelinePositionPercent(date: string, range: Pick, 'start' | 'end'>) { if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration') return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100)) } export function containedIds(state: CaseState, widgetId: string) { return state.relations .filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map(relation => relation.toExhibitId) } export function folderIsOpen(folder: FolderExhibit) { return folder.isOpen } export function relationPosition(state: CaseState, relation: ExhibitRelation) { const target = state.exhibits.find(exhibit => exhibit.id === relation.toExhibitId) if (target) return { x: target.x, y: target.y } const folder = state.exhibits.find(exhibit => exhibit.id === relation.fromExhibitId) const order = relation.sortOrder || 0 return { x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205, y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185, } } export function discardExhibit(state: CaseState, exhibitId: string): CaseState { if (!state.exhibits.some(exhibit => exhibit.id === exhibitId)) return state return { ...state, exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId), relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId), connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId), brief: { ...state.brief, concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId ? { ...concept, resolvedPartyExhibitId: undefined } : concept), }, } } export function defaultTimelineView(range?: TimelineRange | null): BoardView { return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0, rangeMode: range ? 'fixed' : 'auto', range: range || undefined } } type LegacyCaseState = { id: string title: string subtitle: string viewport: Viewport brief?: CaseState['brief'] goals?: CaseState['goals'] report?: CaseState['report'] updatedAt?: string levelStatus?: string sourceTemplateVersionId?: string editingAllowed?: boolean revision?: number newlyVisibleDocumentIds?: string[] exhibits?: Exhibit[] views?: BoardView[] documents?: Array> evidence?: Array> timelineRange?: TimelineRange | null relations?: Array> connections?: Array> } function placement(item: Record, defaults: { width: number; height: number }, index: number) { return { x: Number(item.x ?? 100), y: Number(item.y ?? 100), width: Number(item.width ?? defaults.width), height: Number(item.height ?? defaults.height), rotation: Number(item.rotation ?? 0), zIndex: Number(item.zIndex ?? index), hidden: Boolean(item.hidden) } } function sourceFileType(value: unknown, mimeType: unknown): SourceFileType { const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file'] if (allowed.includes(value as SourceFileType)) return value as SourceFileType return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file' } /** Normalizes current API state and upgrades disposable pre-registry browser caches. */ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState { const state = input as LegacyCaseState if (Array.isArray(state.exhibits)) { return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[], revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], report: state.report, views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)], exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })), updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed, newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [], } } const legacyEvidence = state.evidence || [] const legacyRelations = state.relations || [] const documentPositions = new Map(legacyRelations.filter(relation => relation.type === 'contains').map(relation => [String(relation.toWidgetId), { x: Number((relation.config as Record | undefined)?.x ?? 100), y: Number((relation.config as Record | undefined)?.y ?? 100), }])) const documents: Exhibit[] = (state.documents || []).map((document, index) => ({ id: String(document.id), type: 'document', title: String(document.title || ''), ...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index), publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined, sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [], sourceCitation: String(document.sourceCitation || '') || undefined, regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined, fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined, fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize), fileType: sourceFileType(document.fileType, document.mimeType), metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record : {}, } as Exhibit)) const evidence: Exhibit[] = legacyEvidence.map((item, index) => { const type = item.type === 'evidence' ? 'folder' : item.type const common = { id: String(item.id), type, title: String(item.title || ''), content: String(item.content || ''), ...placement(item, { width: 240, height: 160 }, documents.length + index) } if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record | undefined)?.open === true } if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined } if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] } return { ...common, type: 'note' } }) const derivedRelations: ExhibitRelation[] = [ ...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })), ...legacyEvidence.flatMap(item => Array.isArray(item.supportingEvidenceIds) ? item.supportingEvidenceIds.map((id, index) => ({ id: `supports:${item.id}:${id}`, type: 'supports' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []), ...legacyEvidence.flatMap(item => Array.isArray(item.relatedEvidenceIds) ? item.relatedEvidenceIds.map((id, index) => ({ id: `concerns:${item.id}:${id}`, type: 'concerns' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []), ...legacyEvidence.flatMap(item => item.sourceDocumentId ? [{ id: `source:${item.id}`, type: 'source' as const, fromExhibitId: String(item.id), toExhibitId: String(item.sourceDocumentId), sourceRegionId: String(item.sourceRegionId || '') || undefined, sortOrder: 0 }] : []), ] const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id), fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection)) return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections, views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0), updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed, newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] } }