2026-08-14 15:40:54 +02:00
|
|
|
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
2026-08-14 12:57:06 +02:00
|
|
|
|
2026-08-14 16:37:50 +02:00
|
|
|
export interface BoardPoint { x: number; y: number }
|
|
|
|
|
|
2026-08-14 18:26:20 +02:00
|
|
|
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
2026-08-14 16:37:50 +02:00
|
|
|
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 }
|
2026-08-14 18:26:20 +02:00
|
|
|
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,
|
2026-08-14 16:37:50 +02:00
|
|
|
}
|
2026-08-14 18:26:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-14 16:37:50 +02:00
|
|
|
return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 12:57:06 +02:00
|
|
|
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) {
|
2026-08-14 15:45:03 +02:00
|
|
|
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)
|
2026-08-14 12:57:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-14 16:44:43 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 12:57:06 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 15:02:00 +02:00
|
|
|
export function nextOpenBoardPosition(
|
|
|
|
|
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[],
|
|
|
|
|
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) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 12:57:06 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 15:40:54 +02:00
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-14 12:57:06 +02:00
|
|
|
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<ReturnType<typeof timelineRange>, '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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 19:37:43 +02:00
|
|
|
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
|
|
|
|
|
if (!state.evidence.some(exhibit => exhibit.id === exhibitId)) return state
|
|
|
|
|
return {
|
|
|
|
|
...state,
|
|
|
|
|
evidence: state.evidence
|
|
|
|
|
.filter(exhibit => exhibit.id !== exhibitId)
|
|
|
|
|
.map(exhibit => ({
|
|
|
|
|
...exhibit,
|
|
|
|
|
containedDocumentIds: exhibit.containedDocumentIds?.filter(id => id !== exhibitId),
|
|
|
|
|
supportingEvidenceIds: exhibit.supportingEvidenceIds?.filter(id => id !== exhibitId),
|
|
|
|
|
relatedEvidenceIds: exhibit.relatedEvidenceIds?.filter(id => id !== exhibitId),
|
|
|
|
|
})),
|
|
|
|
|
relations: state.relations.filter(relation => relation.fromWidgetId !== exhibitId && relation.toWidgetId !== exhibitId),
|
|
|
|
|
connections: state.connections.filter(connection => connection.fromEvidenceId !== exhibitId && connection.toEvidenceId !== exhibitId),
|
|
|
|
|
brief: {
|
|
|
|
|
...state.brief,
|
|
|
|
|
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
|
|
|
|
|
? { ...concept, resolvedPartyExhibitId: undefined }
|
|
|
|
|
: concept),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 12:57:06 +02:00
|
|
|
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,
|
2026-08-14 14:44:58 +02:00
|
|
|
brief: state.brief || { body: '', concepts: [] },
|
2026-08-14 12:57:06 +02:00
|
|
|
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),
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|