109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
export const MARKUP_DEFAULT_SHAPE = 'arrow';
|
|
export const MARKUP_DEFAULT_COLOR = '#ff0000';
|
|
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
|
|
|
|
export interface MarkupShape {
|
|
kind: string;
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
width: number;
|
|
color: string;
|
|
}
|
|
|
|
function clampFraction(value: unknown): number {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return 0;
|
|
return Math.max(0, Math.min(1, numeric));
|
|
}
|
|
|
|
export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string {
|
|
if (typeof color !== 'string') return fallback;
|
|
const value = color.trim();
|
|
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
|
}
|
|
|
|
export function sanitizeMarkupShape(
|
|
shape: Partial<MarkupShape> | null | undefined,
|
|
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
|
|
fallbackColor: string = MARKUP_DEFAULT_COLOR,
|
|
fallbackWidth: number = 3,
|
|
): MarkupShape | null {
|
|
if (!shape || typeof shape !== 'object') return null;
|
|
const kind = (shape.kind && ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind)) ? shape.kind : fallbackShape;
|
|
const x1 = clampFraction(shape.x1);
|
|
const y1 = clampFraction(shape.y1);
|
|
const x2 = clampFraction(shape.x2);
|
|
const y2 = clampFraction(shape.y2);
|
|
const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
|
|
return {
|
|
kind,
|
|
x1: Number(x1.toFixed(4)),
|
|
y1: Number(y1.toFixed(4)),
|
|
x2: Number(x2.toFixed(4)),
|
|
y2: Number(y2.toFixed(4)),
|
|
width,
|
|
color: sanitizeMarkupColor(shape.color, fallbackColor),
|
|
};
|
|
}
|
|
|
|
export function parseMarkupShapes(
|
|
markupShapes: unknown,
|
|
fallbackShape: string = MARKUP_DEFAULT_SHAPE,
|
|
fallbackColor: string = MARKUP_DEFAULT_COLOR,
|
|
fallbackWidth: number = 3,
|
|
): MarkupShape[] {
|
|
if (Array.isArray(markupShapes)) {
|
|
return markupShapes
|
|
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
|
.filter((s): s is MarkupShape => s != null);
|
|
}
|
|
|
|
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(markupShapes);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed
|
|
.map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
|
.filter((s): s is MarkupShape => s != null);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function getArrowGeometry(shape: MarkupShape, imageWidth: number, imageHeight: number) {
|
|
const x1 = shape.x1 * imageWidth;
|
|
const y1 = shape.y1 * imageHeight;
|
|
const x2 = shape.x2 * imageWidth;
|
|
const y2 = shape.y2 * imageHeight;
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
const ux = dx / length;
|
|
const uy = dy / length;
|
|
const strokeWidth = Math.max(1, shape.width);
|
|
const headLength = Math.max(10, strokeWidth * 4);
|
|
const headWidth = Math.max(8, strokeWidth * 3);
|
|
const shaftX = x2 - ux * headLength;
|
|
const shaftY = y2 - uy * headLength;
|
|
const px = -uy;
|
|
const py = ux;
|
|
const leftX = shaftX + px * headWidth * 0.5;
|
|
const leftY = shaftY + py * headWidth * 0.5;
|
|
const rightX = shaftX - px * headWidth * 0.5;
|
|
const rightY = shaftY - py * headWidth * 0.5;
|
|
return {
|
|
line: `${x1},${y1} ${shaftX},${shaftY}`,
|
|
head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
|
|
};
|
|
}
|
|
|
|
export function getMarkupPreviewStrokeWidth(width: number, imageWidth: number, imageHeight: number) {
|
|
const normalizedWidth = Math.max(1, Math.round(Number(width) || 1));
|
|
const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0);
|
|
const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM);
|
|
return Math.max(1, Math.round(normalizedWidth * scale));
|
|
}
|