Adding changes to luggage tag

This commit is contained in:
2026-08-23 22:26:20 +02:00
parent 0484a07fff
commit 6fedac6adc
6 changed files with 87 additions and 55 deletions
+1 -5
View File
@@ -153,9 +153,6 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
expect(tagAfter).not.toBeNull()
expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20)
await relationTag.click()
await expect(relationTag).toHaveClass(/\bexpanded\b/)
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
await relationTag.click()
await expect(page.getByText('Edit red thread')).toBeVisible()
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
await page.locator('.tag-style-picker input[value="compact"]').check()
@@ -168,9 +165,8 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver')
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) }))
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: 0 }))
expect(eliasThread.tagPosition).not.toBe(50)
expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19)
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
+1 -2
View File
@@ -626,8 +626,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
const tagOffset = 0
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
}
+34 -12
View File
@@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
@@ -89,6 +89,7 @@ export function App() {
const [advancing, setAdvancing] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const initiallyFocusedLevelRef = useRef<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const adminMenuRef = useRef<HTMLDivElement>(null)
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
@@ -180,6 +181,29 @@ export function App() {
return () => window.clearTimeout(timer)
}, [arrivingExhibitIds])
useLayoutEffect(() => {
if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return
const board = boardRef.current
const primaryClaim = caseState.exhibits.find(exhibit => exhibit.type === 'claim' && !exhibit.hidden)
if (!board || !primaryClaim) return
const bounds = board.getBoundingClientRect()
if (!bounds.width || !bounds.height) return
// In portrait the vertical tool rail occupies part of the apparent canvas.
// Centre the claim in the remaining usable board rather than underneath it.
const toolRail = board.parentElement?.querySelector<HTMLElement>('.board-actions')
const toolBounds = toolRail?.getBoundingClientRect()
const portraitToolInset = toolBounds && toolBounds.height > toolBounds.width * 2
? Math.max(0, bounds.right - toolBounds.left + 8)
: 0
const viewport = { ...caseState.viewport, zoom: clampBoardZoom(Math.max(caseState.viewport.zoom, .85)) }
initiallyFocusedLevelRef.current = caseState.id
setCaseState(current => current?.id === caseState.id ? {
...current,
viewport: viewportCenteredOnExhibit(viewport, primaryClaim, { width: bounds.width, height: bounds.height }, { right: portraitToolInset }),
} : current)
}, [caseState])
const update = useCallback((fn: (state: CaseState) => CaseState) => {
setCaseState(current => {
if (!current) return current
@@ -201,7 +225,10 @@ export function App() {
const ev = caseState.exhibits.find(e => e.id === id)
if (!ev) return
setSelected(id)
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
const bounds = boardRef.current?.getBoundingClientRect()
update(s => ({ ...s, viewport: bounds
? viewportCenteredOnExhibit(s.viewport, ev, { width: bounds.width, height: bounds.height })
: s.viewport }))
}
const extract = (doc: CaseDocument, regionId: string) => {
@@ -788,7 +815,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
const pinchDistance = useRef<number | null>(null)
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
const [draggingWidget, setDraggingWidget] = useState(false)
const [trashActive, setTrashActive] = useState(false)
@@ -887,7 +913,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
setTrashActive(overTrash)
}
if (active.kind === 'thread-tag' && boardRef.current) {
if (active.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === active.id)
const from = connection ? pointForId(connection.fromExhibitId) : undefined
const to = connection ? pointForId(connection.toExhibitId) : undefined
@@ -895,7 +920,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
const bounds = boardRef.current.getBoundingClientRect()
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: 0 } : item) }))
}
} else if (active.kind === 'widget') update(s => { const next = moveBoardPoint({ x: active.originX, y: active.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === active.id ? { ...exhibit, ...next } : exhibit) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: active.originX, y: active.originY }, { x: dx, y: dy }) }))
@@ -953,7 +978,6 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
onPointerDown={e => {
const target = e.target as HTMLElement
if (!target.closest('.thread-tag')) setExpandedThreadTagId(null)
const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button')
if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) }
}}
@@ -965,7 +989,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg>
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : 'Drag along thread · click to edit' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } onEditConnection(connection) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b></span>)}</button> })}
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
{state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
</svg>
@@ -1198,17 +1222,15 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50)
const save = (tag = label) => {
const lateralLimit = threadTagLateralLimit(tightness)
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition,
tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) })
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, tagOffset: 0 })
}
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
<p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Physical · hangs from the thread</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board to slide it along the red thread.</small></label>
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
</div>
+25 -5
View File
@@ -19,6 +19,7 @@ import {
threadTagPlacement,
timelinePositionPercent,
timelineRange,
viewportCenteredOnExhibit,
zoomFromWheel,
zoomFromPinch,
zoomViewportAt,
@@ -40,13 +41,14 @@ describe('red thread geometry', () => {
it('places and projects tags by percentage along the curve', () => {
const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 })
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
})
it('reduces lateral tag travel as the thread becomes taut', () => {
expect(threadTagLateralLimit(0)).toBe(70)
expect(threadTagLateralLimit(100)).toBe(10)
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10)
it('constrains tags to the thread regardless of thread tension', () => {
expect(threadTagLateralLimit(0)).toBe(0)
expect(threadTagLateralLimit(100)).toBe(0)
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100)).toMatchObject({ x: 100, y: 0, lateralOffset: 0 })
expect(projectThreadTag({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, { x: 150, y: 80 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
})
})
@@ -113,6 +115,24 @@ describe('board coordinate math', () => {
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
})
it('centres an exhibit in the usable mobile canvas', () => {
const viewport = viewportCenteredOnExhibit(
{ x: 0, y: 28, zoom: .85 },
{ x: 940, y: 360, width: 330, height: 190 },
{ width: 390, height: 658 },
{ right: 64 },
)
expect(viewport).toEqual({ x: -776.25, y: -57.75, zoom: .85 })
expect(viewport.x + (940 + 330 / 2) * viewport.zoom).toBe(163)
expect(viewport.y + (360 + 190 / 2) * viewport.zoom).toBe(329)
})
it('rejects invalid zoom when centring an exhibit', () => {
expect(() => viewportCenteredOnExhibit(
{ x: 0, y: 0, zoom: 0 }, folder, { width: 390, height: 658 },
)).toThrow(RangeError)
})
it('places new exhibits in deterministic open slots', () => {
const preferred = { x: 500, y: 400 }
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
+23 -21
View File
@@ -21,17 +21,8 @@ function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardP
}
}
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 threadTagLateralLimit(_tightness = 65) {
return 0
}
export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) {
@@ -39,12 +30,9 @@ export function threadTagPlacement(from: BoardPoint, to: BoardPoint, 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 }
return { x: point.x, y: point.y, positionPercent: position, lateralOffset: offset, maxLateralOffset }
}
export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) {
@@ -57,13 +45,8 @@ export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: nu
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 }
return { positionPercent: Math.round(bestT * 100), lateralOffset: 0, maxLateralOffset }
}
export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
@@ -126,6 +109,25 @@ export function nextOpenBoardPosition(
export interface BoardScreenSize { width: number; height: number }
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }
/** Centres an exhibit in the usable portion of the board viewport. */
export function viewportCenteredOnExhibit(
viewport: Viewport,
exhibit: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>,
screen: BoardScreenSize,
insets: BoardScreenInsets = {},
): Viewport {
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
const left = Math.max(0, insets.left || 0)
const top = Math.max(0, insets.top || 0)
const usableWidth = Math.max(1, screen.width - left - Math.max(0, insets.right || 0))
const usableHeight = Math.max(1, screen.height - top - Math.max(0, insets.bottom || 0))
return {
x: left + usableWidth / 2 - (exhibit.x + exhibit.width / 2) * viewport.zoom,
y: top + usableHeight / 2 - (exhibit.y + exhibit.height / 2) * viewport.zoom,
zoom: viewport.zoom,
}
}
/**
* Finds an open board position inside the portion of the canvas the player can
* currently see. Screen insets reserve space for board chrome such as the mobile
+3 -10
View File
@@ -80,19 +80,15 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag.untagged i { display: none; }
.thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
.thread-tag.compact { transform: translate(-4.5px, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
.thread-tag.compact i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
.thread-tag-compact-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; }
.thread-tag.compact:hover i, .thread-tag.compact:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, -13px); }
.thread-tag.labelled i { display: none; }
.thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; }
.thread-tag-content small { padding-bottom: 4px; border-bottom: 1px solid #7e6542; color: #5b472d; font: 600 6px IBM Plex Mono; letter-spacing: .1em; }
.thread-tag-content b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
.thread-tag-content em { color: #68472c; font: 600 6px IBM Plex Mono; letter-spacing: .06em; }
.thread-tag.labelled.expanded { z-index: 14; transform: translate(-50%, 4px) rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.thread-tag.labelled.expanded .thread-tag-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.thread-tag.labelled.expanded .thread-tag-content b { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
@@ -238,10 +234,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
.evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
.evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; }
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; filter: drop-shadow(7px 9px 5px #0007); }
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }