Adding changes to luggage tag
This commit is contained in:
+34
-12
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user