import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types' 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 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 + 160 + 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.fromWidgetId === widgetId) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map(relation => relation.toWidgetId) } export function folderIsOpen(folder: Evidence) { return folder.config?.open === true } export function relationPosition(state: CaseState, relation: WidgetRelation) { const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId) const order = relation.sortOrder || 0 const configuredX = Number(relation.config?.x) const configuredY = Number(relation.config?.y) return { x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205, y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185, } } export function normalizeCase(state: CaseState): CaseState { const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index, }))) const normalized = { ...state, relations } return { ...normalized, brief: state.brief || { body: '', concepts: [] }, documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {}, })), evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: containedIds(normalized, widget.id), })), } }