259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|