Files
PacketStation/frontend/src/packet/marks.tsx
T

259 lines
7.2 KiB
TypeScript
Raw Normal View History

import { useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import type { HeatTablePlacement, HeatTag, Weld } from "../api/types";
import { arrowRotation, bpos, clamp01 } from "../lib/geometry";
import { clampCorner, heatGroups, NO_HEAT, type HeatRow } from "../lib/heatTable";
import { weldId } from "../lib/weldDefaults";
import { useViewer } from "./viewerContext";
/** A mark's position while it is dragged, before the server has it. */
interface Pos {
fx: number;
fy: number;
bfx: number;
bfy: number;
}
export interface MarkMove {
/** Only the bubble moved. */
bubble: boolean;
fx: number;
fy: number;
bfx: number;
bfy: number;
}
const pct = (v: number) => `${v * 100}%`;
/**
* One leader mark: the line, the arrowhead at the point and the bubble. Shared by welds (pink) and the older heat
* bubbles (amber). The bubble and the arrowhead drag when editable; a tap on the bubble opens it.
*/
function Leader({
kind,
at,
editable,
carried,
label,
bubble,
bubbleClass,
onOpen,
onMove,
}: {
kind: "w" | "h";
at: Pos;
editable: boolean;
carried: boolean;
label: string;
bubble: ReactNode;
bubbleClass: string;
onOpen: () => void;
onMove: (m: MarkMove) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${at.fx},${at.fy},${at.bfx},${at.bfy}`;
// The dragged spot shows until the server's answer moves the mark (a new key) or the write fails.
const [drag, setDrag] = useState<{ key: string; pos: Pos } | null>(null);
const pos = drag && drag.key === key ? drag.pos : at;
const rot = arrowRotation(pos.fx, pos.fy, pos.bfx, pos.bfy, v.aspect);
const begin = (e: ReactPointerEvent, part: "anchor" | "bubble") => {
if (!editable) return;
let last: Pos | null = null;
v.startDrag(e, {
onMove: (f) => {
const x = clamp01(f.fx);
const y = clamp01(f.fy);
last = part === "anchor" ? { ...at, fx: x, fy: y } : { ...at, bfx: x, bfy: y };
setDrag({ key, pos: last });
},
onEnd: (f) => {
const done = f ? last : null;
if (!done) {
setDrag(null);
return;
}
void onMove({ bubble: part === "bubble", ...done }).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<>
<svg className="lead" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<line className={`ld ${kind}${carried ? " carried" : ""}`} x1={pos.fx} y1={pos.fy} x2={pos.bfx} y2={pos.bfy} />
</svg>
<span
className={`arw ${kind}${editable ? " drag" : ""}`}
data-mark={editable ? "anchor" : undefined}
style={{ left: pct(pos.fx), top: pct(pos.fy), ["--rot" as string]: `${rot}deg` } as CSSProperties}
title={editable ? "Drag to move where the arrow points" : undefined}
aria-hidden="true"
onPointerDown={editable ? (e) => begin(e, "anchor") : undefined}
/>
<button
type="button"
className={`${bubbleClass}${editable ? " drag" : ""}${carried ? " carried" : ""}`}
data-mark="bubble"
style={{ left: pct(pos.bfx), top: pct(pos.bfy) }}
aria-label={label}
onPointerDown={editable ? (e) => begin(e, "bubble") : undefined}
onClick={() => {
if (!v.justDragged()) onOpen();
}}
>
{bubble}
</button>
</>
);
}
function posOf(m: { fx: number | null; fy: number | null; bfx?: number | null | undefined; bfy?: number | null | undefined }): Pos {
const b = bpos(m);
return { fx: m.fx ?? 0.5, fy: m.fy ?? 0.5, ...b };
}
export function WeldMark({
weld,
editable,
onOpen,
onMove,
}: {
weld: Weld;
editable: boolean;
onOpen: (w: Weld) => void;
onMove: (w: Weld, m: MarkMove) => Promise<boolean>;
}) {
const id = weldId(weld);
return (
<Leader
kind="w"
at={posOf(weld)}
editable={editable}
carried={!!weld.carried}
label={`Weld ${id}${weld.carried ? ", carried" : ""}`}
bubble={id}
bubbleClass="mk"
onOpen={() => onOpen(weld)}
onMove={(m) => onMove(weld, m)}
/>
);
}
export function HeatTagMark({
tag,
heat,
editable,
onOpen,
onMove,
}: {
tag: HeatTag;
heat: string;
editable: boolean;
onOpen: (t: HeatTag) => void;
onMove: (t: HeatTag, m: MarkMove) => Promise<boolean>;
}) {
return (
<Leader
kind="h"
at={posOf(tag)}
editable={editable}
carried={!!tag.carried}
label={`Heat tag H${tag.tag_no}, item ${tag.item}, ${heat || "no heat"}`}
bubble={
<>
<span className="hn">{tag.item}</span>
<span>{heat || "no heat"}</span>
</>
}
bubbleClass={heat ? "ht" : "ht none"}
onOpen={() => onOpen(tag)}
onMove={(m) => onMove(tag, m)}
/>
);
}
/** The table body: the caption and the groups side by side. */
export function HeatTableInner({ rows }: { rows: readonly HeatRow[] }) {
return (
<>
<div className="htcap">HEAT NUMBERS</div>
<div className="htcols">
{heatGroups(rows).map((g, i) => (
<table key={i}>
<thead>
<tr>
<th>ITEM</th>
<th>HEAT NO.</th>
</tr>
</thead>
<tbody>
{g.map((r) => (
<tr key={r.item}>
<td className="i">{r.item}</td>
<td className={`h${r.heat ? "" : " none"}${r.na ? " na" : ""}`}>{r.heat || NO_HEAT}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
</>
);
}
/** The heat table on the drawing. Its font follows the page width (--sw) and the size (--hs). Drags when editable. */
export function HeatTableOnDrawing({
placement,
rows,
editable,
onMove,
}: {
placement: HeatTablePlacement;
rows: readonly HeatRow[];
editable: boolean;
onMove: (x: number, y: number) => Promise<boolean>;
}) {
const v = useViewer();
const key = `${placement.x},${placement.y}`;
const [drag, setDrag] = useState<{ key: string; x: number; y: number } | null>(null);
const at = drag && drag.key === key ? drag : placement;
const down = (e: ReactPointerEvent) => {
if (!editable) return;
const f0 = v.toFrac(e.clientX, e.clientY);
let last: { x: number; y: number } | null = null;
v.startDrag(e, {
onMove: (f) => {
last = clampCorner(placement.x + (f.fx - f0.fx), placement.y + (f.fy - f0.fy));
setDrag({ key, ...last });
},
onEnd: (f) => {
if (!f || !last) {
setDrag(null);
return;
}
void onMove(last.x, last.y).then((ok) => {
if (!ok) setDrag(null);
});
},
});
};
return (
<div
className={`htbl${editable ? " drag" : ""}${drag && drag.key === key ? " moving" : ""}`}
data-mark={editable ? "table" : undefined}
data-testid="heat-table"
role="group"
aria-label="Heat table on the drawing"
style={{ left: pct(at.x), top: pct(at.y), ["--hs" as string]: String(placement.s || 1) } as CSSProperties}
title={editable ? "Drag to move the heat table" : "Heat numbers from the Materials tab"}
onPointerDown={editable ? down : undefined}
>
<HeatTableInner rows={rows} />
</div>
);
}