Files
awesome-copilot/extensions/draw/lib/model.mjs
T

370 lines
15 KiB
JavaScript

// Drawing data model shared by the extension (Node) and the editor (browser).
// Pure functions only: no DOM and no file system.
export const GRID = 10;
export const SHAPE_TYPES = ["rect", "ellipse", "diamond", "cylinder"];
export const ELEMENT_TYPES = [...SHAPE_TYPES, "text", "arrow", "pen"];
export const COLORS = ["gray", "blue", "green", "yellow", "orange", "red", "purple", "pink"];
export const FILLS = ["none", "soft", "solid"];
export const HEADS = ["end", "start", "both", "none"];
export const ROUTES = ["straight", "elbow", "curve"];
export const FONT_SIZES = { s: 13, m: 15, l: 20, xl: 28 };
export const SIZE_KEYS = Object.keys(FONT_SIZES);
export const PEN_WIDTHS = { s: 1.5, m: 2.5, l: 4, xl: 6 };
export const DEFAULT_SIZES = { rect: [160, 60], ellipse: [160, 80], diamond: [180, 120], cylinder: [120, 80] };
export const LABEL_WEIGHT = 500;
export const TEXT_WEIGHT = 400;
export const MAX_ELEMENTS = 5000;
// The biggest width or height a shape can have.
export const MAX_SIDE = 5000;
// The most points a pen stroke keeps. Low enough that even the biggest stroke still fits in the
// 64 KiB a page can send as it closes (see flushBeacon in public/sync.js).
export const MAX_PEN_POINTS = 2000;
// How far from 0 a position can be. One further out is moved back to it.
export const MAX_COORD = 1e6;
const ID_PREFIX = { rect: "r", ellipse: "o", diamond: "d", cylinder: "c", text: "t", arrow: "a", pen: "p" };
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
export const isNum = (v) => typeof v === "number" && Number.isFinite(v);
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
export const round2 = (v) => Math.round(v * 100) / 100;
export const snap = (v, grid = GRID) => Math.round(v / grid) * grid;
export const isShape = (el) => !!el && SHAPE_TYPES.includes(el.type);
const lower = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
function toNum(v, fallback) {
if (isNum(v)) return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
return fallback;
}
const coord = (v) => round2(clamp(toNum(v, 0), -MAX_COORD, MAX_COORD));
export function newId(prefix = "e") {
const chars = "abcdefghijkmnpqrstuvwxyz23456789";
let s = prefix;
for (let i = 0; i < 7; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
export function uniqueId(prefix, taken) {
let id = newId(prefix);
while (taken.has(id)) id = newId(prefix);
taken.add(id);
return id;
}
export function idPrefix(type) {
return ID_PREFIX[type] || "e";
}
const TYPE_ALIASES = {
rectangle: "rect", box: "rect", square: "rect", process: "rect", node: "rect", shape: "rect", card: "rect", step: "rect",
circle: "ellipse", oval: "ellipse", terminal: "ellipse", pill: "ellipse",
decision: "diamond", rhombus: "diamond", condition: "diamond", choice: "diamond",
database: "cylinder", db: "cylinder", storage: "cylinder", datastore: "cylinder",
label: "text", note: "text", title: "text",
line: "arrow", edge: "arrow", connector: "arrow", link: "arrow",
freehand: "pen", stroke: "pen", sketch: "pen",
};
export function resolveType(raw) {
let t = lower(raw?.type);
if ((!t || t === "shape" || t === "node") && raw?.shape) t = lower(raw.shape);
t = TYPE_ALIASES[t] || t;
return ELEMENT_TYPES.includes(t) ? t : null;
}
export function resolveShapeType(value, fallback = "rect") {
const t = TYPE_ALIASES[lower(value)] || lower(value);
return SHAPE_TYPES.includes(t) ? t : fallback;
}
const COLOR_ALIASES = {
grey: "gray", black: "gray", white: "gray", default: "gray", neutral: "gray", none: "gray",
cyan: "blue", navy: "blue", sky: "blue", teal: "green", lime: "green", emerald: "green",
amber: "yellow", gold: "yellow", violet: "purple", indigo: "purple", lavender: "purple",
magenta: "pink", rose: "pink", crimson: "red", brown: "orange",
};
export function normalizeColor(value, fallback = "gray") {
const s = lower(value);
if (!s) return fallback;
if (COLORS.includes(s)) return s;
if (COLOR_ALIASES[s]) return COLOR_ALIASES[s];
if (/^#[0-9a-f]{6}$/.test(s)) return s;
if (/^#[0-9a-f]{3}$/.test(s)) return "#" + [...s.slice(1)].map((c) => c + c).join("");
return fallback;
}
export function normalizeFill(value, fallback = "soft") {
if (value === true) return "soft";
if (value === false) return "none";
const s = lower(value);
if (FILLS.includes(s)) return s;
const alias = {
outline: "none", transparent: "none", empty: "none", hollow: "none",
light: "soft", tint: "soft", tinted: "soft", pastel: "soft",
filled: "solid", full: "solid", strong: "solid", bold: "solid", dark: "solid",
};
return alias[s] || fallback;
}
export function normalizeHead(value, fallback = "end") {
if (value === false) return "none";
if (value === true) return "end";
const s = lower(value);
if (HEADS.includes(s)) return s;
const alias = {
arrow: "end", forward: "end", to: "end", back: "start", backward: "start", reverse: "start", from: "start",
"two-way": "both", bidirectional: "both", double: "both", line: "none", no: "none", off: "none", plain: "none",
};
return alias[s] || fallback;
}
export function normalizeRoute(value, fallback = "straight") {
const s = lower(value);
if (ROUTES.includes(s)) return s;
const alias = {
direct: "straight", line: "straight", linear: "straight",
orthogonal: "elbow", step: "elbow", elbowed: "elbow", manhattan: "elbow", angled: "elbow",
curved: "curve", bezier: "curve", spline: "curve", smooth: "curve", arc: "curve",
};
return alias[s] || fallback;
}
export function normalizeSize(value, fallback = "m") {
if (isNum(value)) {
let best = fallback;
let diff = Infinity;
for (const k of SIZE_KEYS) {
const d = Math.abs(FONT_SIZES[k] - value);
if (d < diff) { diff = d; best = k; }
}
return best;
}
const s = lower(value);
if (SIZE_KEYS.includes(s)) return s;
const alias = { small: "s", sm: "s", medium: "m", md: "m", normal: "m", large: "l", lg: "l", big: "l", xlarge: "xl", "x-large": "xl", huge: "xl" };
return alias[s] || fallback;
}
export function normalizeText(value) {
if (typeof value === "number") return String(value);
if (typeof value !== "string") return "";
// eslint-disable-next-line no-control-regex
return value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 4000);
}
function refId(value) {
return typeof value === "string" && ID_PATTERN.test(value) ? value : null;
}
// A stroke with more than `max` points keeps an even spread of them, first and last included, so
// it loses detail rather than its end.
export function limitPoints(points, max = MAX_PEN_POINTS) {
if (points.length <= max) return points;
const step = (points.length - 1) / (max - 1);
return Array.from({ length: max }, (_, i) => points[Math.round(i * step)]);
}
function normalizePoints(points) {
if (!Array.isArray(points)) return [];
const out = [];
for (const p of limitPoints(points)) {
const nx = toNum(Array.isArray(p) ? p[0] : p?.x, NaN);
const ny = toNum(Array.isArray(p) ? p[1] : p?.y, NaN);
if (Number.isFinite(nx) && Number.isFinite(ny)) {
out.push([Math.round(clamp(nx, -MAX_COORD, MAX_COORD) * 10) / 10, Math.round(clamp(ny, -MAX_COORD, MAX_COORD) * 10) / 10]);
}
}
return out;
}
// Turns loose input (from the editor, a file or the agent) into a clean element, or null.
export function normalizeElement(raw) {
if (!raw || typeof raw !== "object") return null;
const type = resolveType(raw);
if (!type) return null;
const id = typeof raw.id === "string" && ID_PATTERN.test(raw.id) ? raw.id : newId(ID_PREFIX[type]);
const color = normalizeColor(raw.color);
const dash = Boolean(raw.dash ?? raw.dashed);
if (isShape({ type })) {
const [dw, dh] = DEFAULT_SIZES[type];
return {
id, type,
x: coord(raw.x), y: coord(raw.y),
w: round2(clamp(toNum(raw.w ?? raw.width, dw), 10, MAX_SIDE)),
h: round2(clamp(toNum(raw.h ?? raw.height, dh), 10, MAX_SIDE)),
text: normalizeText(raw.text ?? raw.label),
color, fill: normalizeFill(raw.fill), dash, size: normalizeSize(raw.size ?? raw.fontSize),
};
}
if (type === "text") {
return { id, type, x: coord(raw.x), y: coord(raw.y), text: normalizeText(raw.text ?? raw.label), color, size: normalizeSize(raw.size ?? raw.fontSize) };
}
if (type === "arrow") {
return {
id, type,
from: refId(raw.from ?? raw.source), to: refId(raw.to ?? raw.target),
x1: coord(raw.x1), y1: coord(raw.y1), x2: coord(raw.x2), y2: coord(raw.y2),
text: normalizeText(raw.text ?? raw.label),
color, dash,
head: normalizeHead(raw.head, lower(raw.type) === "line" ? "none" : "end"),
route: normalizeRoute(raw.route),
size: normalizeSize(raw.size ?? raw.fontSize, "s"),
};
}
const points = normalizePoints(raw.points);
if (!points.length) return null;
return { id, type: "pen", points, color, width: round2(clamp(toNum(raw.width ?? raw.strokeWidth, PEN_WIDTHS.m), 0.5, 24)) };
}
// Normalizes a whole list: fixes duplicate ids and drops arrows that point at missing shapes.
// Anything past `limit` elements is dropped.
export function normalizeElements(list, limit = MAX_ELEMENTS) {
const out = [];
const taken = new Set();
for (const raw of Array.isArray(list) ? list.slice(0, limit) : []) {
const el = normalizeElement(raw);
if (!el) continue;
if (taken.has(el.id)) el.id = uniqueId(ID_PREFIX[el.type], taken);
taken.add(el.id);
out.push(el);
}
const shapes = new Set(out.filter(isShape).map((e) => e.id));
return out.filter((el) => {
if (el.type !== "arrow") return true;
if (el.from && !shapes.has(el.from)) return false;
if (el.to && !shapes.has(el.to)) return false;
if (el.from && el.from === el.to) return false;
if (!el.from && !el.to && Math.hypot(el.x2 - el.x1, el.y2 - el.y1) < 1) return false;
return true;
});
}
// Merges element-level changes: { upserts: [element], deletes: [id], order?: [id] }.
export function applyOps(elements, ops = {}, limit = MAX_ELEMENTS) {
return normalizeElements(mergeOps(elements, ops), limit);
}
// The merge step of applyOps, before the list is cleaned up.
export function mergeOps(elements, ops = {}) {
const map = new Map(elements.map((e) => [e.id, e]));
for (const id of Array.isArray(ops.deletes) ? ops.deletes : []) map.delete(id);
for (const raw of Array.isArray(ops.upserts) ? ops.upserts : []) {
const el = normalizeElement(raw);
if (el) map.set(el.id, el);
}
let list = [...map.values()];
if (Array.isArray(ops.order) && ops.order.length) {
const rank = new Map(ops.order.map((id, i) => [id, i]));
const known = list.filter((e) => rank.has(e.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
list = [...known, ...list.filter((e) => !rank.has(e.id))];
}
return list;
}
// Whether two JSON values hold the same data, whatever order their keys are in.
export function sameJson(a, b) {
if (a === b) return true;
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (!sameJson(a[i], b[i])) return false;
return true;
}
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
for (const k of keys) if (!Object.hasOwn(b, k) || !sameJson(a[k], b[k])) return false;
return true;
}
// The ops that turn the elements in `before` (a Map by id) into `elements`, as mergeOps applies
// them, or null when nothing changed.
export function diffOps(before, elements) {
const upserts = [];
const ids = new Set();
for (const el of elements) {
ids.add(el.id);
const prev = before.get(el.id);
if (!prev || !sameJson(prev, el)) upserts.push(el);
}
const deletes = [...before.keys()].filter((id) => !ids.has(id));
// mergeOps keeps existing ids in place and appends new ones, so order is only sent when that differs.
const mergedOrder = [...before.keys()].filter((id) => ids.has(id));
for (const el of elements) if (!before.has(el.id)) mergedOrder.push(el.id);
const orderChanged = mergedOrder.some((id, i) => id !== elements[i].id);
if (!upserts.length && !deletes.length && !orderChanged) return null;
const ops = { upserts, deletes };
if (orderChanged) ops.order = elements.map((e) => e.id);
return ops;
}
// Removes elements plus any arrows attached to them.
export function removeWithArrows(elements, ids) {
const del = new Set(ids);
for (const el of elements) if (el.type === "arrow" && (del.has(el.from) || del.has(el.to))) del.add(el.id);
return {
elements: elements.filter((e) => !del.has(e.id)),
removed: elements.filter((e) => del.has(e.id)).map((e) => e.id),
};
}
// ---------- colors ----------
export const LIGHT_PALETTE = {
bg: "#ffffff", ink: "#1f2328",
blue: "#0969da", green: "#1a7f37", yellow: "#9a6700", orange: "#bc4c00",
red: "#cf222e", purple: "#8250df", pink: "#bf3989",
};
export const DARK_PALETTE = {
bg: "#0d1117", ink: "#e6edf3",
blue: "#4493f8", green: "#3fb950", yellow: "#d29922", orange: "#db6d28",
red: "#f85149", purple: "#ab7df8", pink: "#db61a2",
};
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
if (!m) return [0, 0, 0];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const toHex = (rgb) => "#" + rgb.map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join("");
export function mixHex(a, b, t) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return toHex(ca.map((v, i) => v * t + cb[i] * (1 - t)));
}
export function luminance(hex) {
const [r, g, b] = hexToRgb(hex).map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// Text color for a solid background. Dark and white text have equal contrast at about 0.21.
export function textOn(hex) {
return luminance(hex) > 0.21 ? "#1f2328" : "#ffffff";
}
// Returns paint(color, role) resolving element colors to hex for a palette.
// Roles: stroke, soft (tinted fill), solid (strong fill), onSolid (text on solid), ink (text), bg.
export function staticPaint(palette = LIGHT_PALETTE) {
const pal = { ...LIGHT_PALETTE, ...palette };
const dark = luminance(pal.bg) < 0.4;
const base = (color) => (color === "gray" ? pal.ink : String(color).startsWith("#") ? color : pal[color] || pal.ink);
return (color, role) => {
const c = base(color);
switch (role) {
case "soft": return mixHex(c, pal.bg, color === "gray" ? (dark ? 0.12 : 0.06) : (dark ? 0.24 : 0.14));
case "onSolid": return textOn(c);
case "bg": return pal.bg;
default: return c;
}
};
}