140 lines
3.8 KiB
TypeScript
140 lines
3.8 KiB
TypeScript
import type { SVGAttributes } from "react";
|
|
import type {
|
|
SafeVisualAnchor,
|
|
SafeVisualPrimitive,
|
|
SafeVisualStyle,
|
|
} from "../formats";
|
|
|
|
export interface SafeVisualLayerProps {
|
|
readonly size: number;
|
|
readonly visuals: readonly SafeVisualPrimitive[];
|
|
readonly layer: "underlay" | "overlay";
|
|
}
|
|
|
|
function anchorPoint(size: number, anchor: SafeVisualAnchor) {
|
|
if (anchor.kind === "coordinate") {
|
|
return { x: anchor.x, y: anchor.y };
|
|
}
|
|
return {
|
|
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
|
|
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
|
|
};
|
|
}
|
|
|
|
function visualStyle(
|
|
style: SafeVisualStyle | undefined,
|
|
): SVGAttributes<SVGElement> {
|
|
return {
|
|
stroke: style?.stroke ?? "transparent",
|
|
fill: style?.fill ?? "transparent",
|
|
...(style?.strokeWidth === undefined
|
|
? {}
|
|
: { strokeWidth: style.strokeWidth }),
|
|
...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render already-normalized inert drawings with typed SVG properties only.
|
|
* Text is supplied as a React text node; no source markup is ever interpreted.
|
|
*/
|
|
export function SafeVisualLayer({
|
|
size,
|
|
visuals,
|
|
layer,
|
|
}: SafeVisualLayerProps) {
|
|
return (
|
|
<g
|
|
className={`safe-visual-layer safe-visual-layer--${layer}`}
|
|
data-visual-layer={layer}
|
|
>
|
|
{visuals.map((visual, index) => {
|
|
if (visual.layer !== layer) return null;
|
|
const common = {
|
|
...visualStyle(visual.style),
|
|
className: `source-visual source-visual--${visual.type}`,
|
|
"data-visual": index,
|
|
};
|
|
if (visual.type === "line") {
|
|
const start = anchorPoint(size, visual.start);
|
|
const end = anchorPoint(size, visual.end);
|
|
return (
|
|
<line
|
|
key={index}
|
|
{...common}
|
|
x1={start.x}
|
|
y1={start.y}
|
|
x2={end.x}
|
|
y2={end.y}
|
|
/>
|
|
);
|
|
}
|
|
if (visual.type === "polyline") {
|
|
const points = visual.points
|
|
.map((anchor) => anchorPoint(size, anchor))
|
|
.map(({ x, y }) => `${String(x)},${String(y)}`)
|
|
.join(" ");
|
|
return visual.closed === true ? (
|
|
<polygon key={index} {...common} points={points} />
|
|
) : (
|
|
<polyline key={index} {...common} points={points} />
|
|
);
|
|
}
|
|
if (visual.type === "rectangle") {
|
|
const center = anchorPoint(size, visual.center);
|
|
return (
|
|
<rect
|
|
key={index}
|
|
{...common}
|
|
x={center.x - visual.width / 2}
|
|
y={center.y - visual.height / 2}
|
|
width={visual.width}
|
|
height={visual.height}
|
|
rx={visual.cornerRadius}
|
|
/>
|
|
);
|
|
}
|
|
if (visual.type === "ellipse") {
|
|
const center = anchorPoint(size, visual.center);
|
|
return (
|
|
<ellipse
|
|
key={index}
|
|
{...common}
|
|
cx={center.x}
|
|
cy={center.y}
|
|
rx={visual.radiusX}
|
|
ry={visual.radiusY}
|
|
/>
|
|
);
|
|
}
|
|
if (visual.type === "circle") {
|
|
const center = anchorPoint(size, visual.center);
|
|
return (
|
|
<circle
|
|
key={index}
|
|
{...common}
|
|
cx={center.x}
|
|
cy={center.y}
|
|
r={visual.radius}
|
|
/>
|
|
);
|
|
}
|
|
const position = anchorPoint(size, visual.position);
|
|
return (
|
|
<text
|
|
key={index}
|
|
{...common}
|
|
x={position.x}
|
|
y={position.y}
|
|
fontSize={visual.style?.fontSize}
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
>
|
|
{visual.text}
|
|
</text>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
}
|