feat: drag relation tags along threads
This commit is contained in:
+33
-6
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
||||
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
||||
|
||||
const BOARD_W = 2400
|
||||
@@ -197,7 +197,7 @@ export function App() {
|
||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||
return
|
||||
}
|
||||
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage' })
|
||||
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
setLinkFrom(null)
|
||||
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
|
||||
}
|
||||
@@ -453,12 +453,13 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
||||
}
|
||||
|
||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchDistance = useRef<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 byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
||||
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
||||
const pointForId = (id: string) => {
|
||||
@@ -502,6 +503,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
|
||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
||||
}
|
||||
const threadTagPointerDown = (event: React.PointerEvent, id: string) => {
|
||||
event.stopPropagation()
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
setDraggingThreadTagId(id)
|
||||
drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 }
|
||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
||||
}
|
||||
const trackThreadPointer = (event: React.PointerEvent) => {
|
||||
if (linkFrom && boardRef.current) {
|
||||
const bounds = boardRef.current.getBoundingClientRect()
|
||||
@@ -530,7 +539,18 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
if (!drag.current) return
|
||||
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
|
||||
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
|
||||
if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
||||
if (drag.current.kind === 'thread-tag' && boardRef.current) {
|
||||
if (drag.current.moved) setExpandedThreadTagId(null)
|
||||
const connection = state.connections.find(item => item.id === drag.current!.id)
|
||||
const from = connection ? pointForId(connection.fromEvidenceId) : undefined
|
||||
const to = connection ? pointForId(connection.toEvidenceId) : undefined
|
||||
if (connection && from && to) {
|
||||
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) }))
|
||||
}
|
||||
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
||||
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
|
||||
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||
}
|
||||
@@ -541,6 +561,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
}
|
||||
if (drag.current) suppressClick.current = Boolean(drag.current.moved)
|
||||
drag.current = null
|
||||
setDraggingThreadTagId(null)
|
||||
}
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
||||
@@ -558,7 +579,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); 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.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === 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' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title={connection.label ? compact ? 'Edit compact relation tag' : expanded ? 'Click again to edit this thread' : 'Rotate relation tag to read' : 'Edit thread tag and tightness'} onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{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.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = 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: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.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(placement.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> })}
|
||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
||||
const evidence = byId.get(evidenceId)
|
||||
@@ -679,13 +700,19 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
||||
const [label, setLabel] = useState(connection.label || '')
|
||||
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
||||
const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
|
||||
const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle })
|
||||
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)) })
|
||||
}
|
||||
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 mean? Add a short tag if the thread represents a specific claim.</p>
|
||||
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" 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>
|
||||
<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>
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
normalizeCase,
|
||||
panViewport,
|
||||
relationPosition,
|
||||
projectThreadTag,
|
||||
threadCurve,
|
||||
threadTagLateralLimit,
|
||||
threadTagPlacement,
|
||||
timelinePositionPercent,
|
||||
timelineRange,
|
||||
zoomFromWheel,
|
||||
@@ -31,6 +34,18 @@ describe('red thread geometry', () => {
|
||||
it('clamps tightness to its normalized percentage range', () => {
|
||||
expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100))
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
const folder: Evidence = {
|
||||
|
||||
+59
-4
@@ -2,16 +2,71 @@ import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } fro
|
||||
|
||||
export interface BoardPoint { x: number; y: number }
|
||||
|
||||
export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||
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 }
|
||||
const midpoint = {
|
||||
x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8,
|
||||
y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8,
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -68,8 +68,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.luggage-tag.luggage-tag { background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; }
|
||||
.luggage-tag.luggage-tag::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
|
||||
.luggage-tag.luggage-tag::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -1px; height: 4px; background: #745a37; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
|
||||
.thread-tag { position: absolute; z-index: 7; border: 0; cursor: pointer; }
|
||||
.thread-tag { position: absolute; z-index: 7; border: 0; cursor: grab; touch-action: none; transition: left .1s ease-out, top .1s ease-out, transform .22s ease, filter .22s ease, opacity .15s ease; }
|
||||
.board-viewport.threading .thread-tag { pointer-events: none; opacity: .72; }
|
||||
.thread-tag.dragging { z-index: 15; cursor: grabbing; transition: transform .12s ease, filter .12s ease; filter: drop-shadow(0 0 7px #d99a58aa); }
|
||||
.thread-tag-position { position: absolute; z-index: 8; left: 50%; top: -22px; translate: -50% 0; min-width: 34px; padding: 3px 5px; border: 1px solid #9a683d; background: #102a24; color: #e6a45e; font: 7px IBM Plex Mono; box-shadow: 2px 3px #02090799; }
|
||||
.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; }
|
||||
@@ -195,7 +197,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
|
||||
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
|
||||
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
|
||||
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface Connection {
|
||||
/** Percentage from slack (0) to taut (100). */
|
||||
tightness?: number
|
||||
tagStyle?: 'luggage' | 'compact'
|
||||
/** Percentage along the thread from its source exhibit. */
|
||||
tagPosition?: number
|
||||
/** Signed perpendicular distance from the thread, constrained by tightness. */
|
||||
tagOffset?: number
|
||||
}
|
||||
|
||||
export interface Viewport { x: number; y: number; zoom: number }
|
||||
|
||||
Reference in New Issue
Block a user