diff --git a/inventory/static/css/components/draw.css b/inventory/static/css/components/draw.css index df65191..dd9927b 100644 --- a/inventory/static/css/components/draw.css +++ b/inventory/static/css/components/draw.css @@ -8,8 +8,6 @@ Shared basics (both modes) ------------------------- */ -.grid-widget { /* no container-type here */ } - /* drawing stack */ .grid-widget [data-grid] { position: relative; diff --git a/inventory/static/js/components/draw.js b/inventory/static/js/components/draw.js index 6d5127f..cc725fd 100644 --- a/inventory/static/js/components/draw.js +++ b/inventory/static/js/components/draw.js @@ -51,6 +51,312 @@ function initGridWidget(root, opts = {}) { let ctx; let dpr = 1; + function shortenKeys(shapes) { + const keyMap = { + type: 't', + points: 'p', + color: 'cl', // avoid collision with x2 + strokeWidth: 'sw', + strokeOpacity: 'so', + fillOpacity: 'fo', + fill: 'f', + + x: 'x', + y: 'y', + w: 'w', + h: 'h', + + x1: 'a', + y1: 'b', + x2: 'c', + y2: 'd' + }; + + return shapes.map((shape) => { + const out = {}; + for (const key of Object.keys(shape)) { + const newKey = keyMap[key] || key; + out[newKey] = shape[key]; + } + return out; + }); + } + + function shortenShapes(shapes) { + const shapeMap = { path: 'p', line: 'l', rect: 'r', ellipse: 'e', stateChange: 's' }; + return shapes.map(shape => ({ + ...shape, + type: shapeMap[shape.type] || shape.type + })); + } + + function collapseStateChanges(shapes) { + const out = []; + let pending = null; + + const flush = () => { + if (pending) out.push(pending); + pending = null; + }; + + for (const shape of shapes) { + if (shape.type === "stateChange") { + if (!pending) pending = { ...shape }; + else { + for (const [k, v] of Object.entries(shape)) { + if (k !== "type") pending[k] = v; + } + } + continue; + } + + flush(); + out.push(shape); + } + + flush(); + return out; + } + + function stateCode(shapes) { + const state = { + ...SHAPE_DEFAULTS, + color: "#000000", + fill: false, + fillOpacity: 1 + }; + + const styleKeys = Object.keys(state); + const out = []; + + for (const shape of shapes) { + const s = { ...shape }; + const stateChange = {}; + + for (const key of styleKeys) { + if (!(key in s)) continue; + + if (s[key] !== state[key]) { + stateChange[key] = s[key]; + state[key] = s[key]; + } + + delete s[key]; + } + + if (Object.keys(stateChange).length > 0) { + out.push({ type: "stateChange", ...stateChange }); + } + + out.push(s); + } + + return out; + } + + function computeDeltas(shapes) { + return shapes.map(shape => { + if (shape.type === 'stateChange') return shape; + + const s = { ...shape }; + let points = []; + + if (s.type === 'path') { + if (!Array.isArray(s.points) || s.points.length === 0) return s; + + points = [Math.round(s.points[0].x * 100), Math.round(s.points[0].y * 100)]; + let prev = s.points[0]; + + for (let i = 1; i < s.points.length; i++) { + const cur = s.points[i]; + points.push(Math.round((cur.x - prev.x) * 100), Math.round((cur.y - prev.y) * 100)); + prev = cur; + } + } else if (s.type === 'line') { + points = [ + Math.round(s.x1 * 100), + Math.round(s.y1 * 100), + Math.round((s.x2 - s.x1) * 100), + Math.round((s.y2 - s.y1) * 100) + ]; + delete s.x1; delete s.y1; delete s.x2; delete s.y2; + } else if (s.type === 'rect' || s.type === 'ellipse') { + points = [ + Math.round(s.x * 100), + Math.round(s.y * 100), + Math.round(s.w * 100), + Math.round(s.h * 100) + ]; + delete s.x; delete s.y; delete s.w; delete s.h; + } + + s.points = points; + return s; + }); + } + + function encodeRuns(shapes) { + const out = []; + let run = null; + + const flush = () => { + if (!run) return; + out.push(run); + run = null; + }; + + for (const shape of shapes) { + if (shape.type === 'path' || shape.type === 'stateChange') { + flush(); + out.push(shape); + continue; + } + + if (!run) { + run = { ...shape, points: [...shape.points] }; + continue; + } + + if (shape.type === run.type) { + run.points.push(...shape.points); + } else { + flush(); + run = { ...shape, points: [...shape.points] }; + } + } + + flush(); + return out; + } + + function encode() { + const payload = { + v: 1, + cs: cellSize, + q: 100, + d: { + cl: "#000000", + f: false, + sw: 12, + so: 100, + fo: 100 + }, + s: shortenKeys(shortenShapes(encodeRuns(computeDeltas(collapseStateChanges(stateCode(stripCaches(shapes))))))) + }; + return payload; + } + + function decodeLine(arr, q) { + const [x1, y1, dx, dy] = arr; + const x2 = x1 + dx; + const y2 = y1 + dy; + return { x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q }; + } + + function decodePath(arr, q) { + let x = arr[0], y = arr[1]; + const pts = [{ x: x / q, y: y / q }]; + for (let i = 2; i < arr.length; i += 2) { + x += arr[i]; + y += arr[i + 1]; + pts.push({ x: x / q, y: y / q }); + } + return pts; + } + + function decode(doc) { + const q = Number(doc?.q) || 100; + const cs = Number(doc?.cs) || 25; + + const defaults = doc?.d || {}; + const state = { + color: defaults.cl ?? "#000000", + fill: !!defaults.f, + strokeWidth: (Number(defaults.sw) ?? 12) / 100, + strokeOpacity: (Number(defaults.so) ?? 100) / 100, + fillOpacity: (Number(defaults.fo) ?? 100) / 100 + }; + + const outShapes = []; + + const applyStateChange = (op) => { + if ("cl" in op) state.color = op.cl; + if ("f" in op) state.fill = !!op.f; + if ("sw" in op) state.strokeWidth = Number(op.sw) / 100; + if ("so" in op) state.strokeOpacity = Number(op.so) / 100; + if ("fo" in op) state.fillOpacity = Number(op.fo) / 100; + }; + + const ops = Array.isArray(doc?.s) ? doc.s : []; + for (const op of ops) { + if (!op || typeof op !== "object") continue; + + const t = op.t; + if (t === "s") { + applyStateChange(op); + continue; + } + + const arr = op.p; + if (!Array.isArray(arr) || arr.length === 0) continue; + + if (t === "p") { + if (arr.length < 2 || (arr.length % 2) !== 0) continue; + + outShapes.push({ + type: "path", + points: decodePath(arr, q), + color: state.color, + strokeWidth: state.strokeWidth, + strokeOpacity: state.strokeOpacity + }); + continue; + } + + if ((arr.length % 4) !== 0) continue; + + if (t === "l") { + for (let i = 0; i < arr.length; i += 4) { + const seg = decodeLine(arr.slice(i, i + 4), q); + outShapes.push({ + type: "line", + x1: seg.x1, y1: seg.y1, x2: seg.x2, y2: seg.y2, + color: state.color, + strokeWidth: state.strokeWidth, + strokeOpacity: state.strokeOpacity + }); + } + continue; + } + + if (t === "r" || t === "e") { + for (let i = 0; i < arr.length; i += 4) { + const x = arr[i] / q; + const y = arr[i + 1] / q; + const w = arr[i + 2] / q; + const h = arr[i + 3] / q; + + outShapes.push({ + type: (t === "r") ? "rect" : "ellipse", + x, y, w, h, + color: state.color, + fill: state.fill, + fillOpacity: state.fillOpacity, + strokeWidth: state.strokeWidth, + strokeOpacity: state.strokeOpacity + }); + } + continue + } + } + + return { + version: Number(doc?.v) || 1, + cellSize: cs, + shapes: outShapes + }; + } + function clamp01(n, fallback = 1) { const x = Number(n); return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback; @@ -367,7 +673,8 @@ function initGridWidget(root, opts = {}) { destroy() { ro.disconnect(); window.removeEventListener('resize', resizeAndSetupCanvas); - } + }, + decode }; } @@ -1060,7 +1367,7 @@ function initGridWidget(root, opts = {}) { const reader = new FileReader(); reader.onload = () => { try { - const data = JSON.parse(reader.result); + const data = decode(JSON.parse(reader.result)); if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) { cellSizeEl.value = data.cellSize; @@ -1093,11 +1400,14 @@ function initGridWidget(root, opts = {}) { }); exportEl.addEventListener('click', () => { + /* const payload = { version: 1, cellSize: cellSize, shapes: stripCaches(shapes) }; + */ + const payload = encode(); const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -1313,7 +1623,7 @@ function initGridWidget(root, opts = {}) { if (script?.textContent?.trim()) { try { const parsed = JSON.parse(script.textContent); - api.setDoc(parsed); + api.setDoc(api.decode(parsed)); } catch (err) { console.error("viewer JSON.parse failed:", err, script.textContent); } @@ -1324,14 +1634,14 @@ function initGridWidget(root, opts = {}) { if (src) { fetch(src, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) - .then(doc => api.setDoc(doc)) + .then(doc => api.setDoc(api.decode(doc))) .catch(() => { }); return; } const raw = root.dataset.doc; if (raw) { - try { api.setDoc(JSON.parse(raw)); } catch { } + try { api.setDoc(JSON.parse(api.decode(raw))); } catch { } } } } diff --git a/inventory/templates/testing.html b/inventory/templates/testing.html index ebb5f38..cf34b04 100644 --- a/inventory/templates/testing.html +++ b/inventory/templates/testing.html @@ -8,7 +8,7 @@ {% block main %} {% set jsonImage %} -{"version":1,"cellSize":5,"shapes":[{"type":"ellipse","x":35,"y":5,"w":15,"h":15,"color":"#ffd35c","fill":true,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":35,"y":5,"w":15,"h":15,"color":"#000000","fill":false,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":36,"y":6,"w":13,"h":13,"color":"#fecdcd","fill":true,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":40,"y":10,"w":5,"h":5,"color":"#fad463","fill":true,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":40,"y":10,"w":5,"h":5,"color":"#000000","fill":false,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":36,"y":6,"w":13,"h":13,"color":"#000000","fill":false,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":41,"y":11,"w":3,"h":3,"color":"#ffffff","fill":true,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"ellipse","x":41,"y":11,"w":3,"h":3,"color":"#000000","fill":false,"fillOpacity":1,"strokeOpacity":1,"strokeWidth":0.12},{"type":"line","x1":39,"y1":9,"x2":40,"y2":8,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":39,"y1":16,"x2":40,"y2":15,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":38,"y1":12,"x2":39,"y2":13,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":41,"y1":18,"x2":42,"y2":17,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":45,"y1":15,"x2":46,"y2":16,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":46,"y1":13,"x2":47,"y2":13,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":10,"x2":46,"y2":10,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":44,"y1":9,"x2":44,"y2":8,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":42,"y1":8,"x2":41,"y2":9,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":44,"y1":17,"x2":44,"y2":18,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":43,"y1":16,"x2":44,"y2":16,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":40,"y1":16,"x2":41,"y2":16,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":38,"y1":14,"x2":39,"y2":14,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":38,"y1":11,"x2":39,"y2":11,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":11,"x2":48,"y2":12,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":15,"x2":46,"y2":14,"color":"#ff0000","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":45,"y1":17,"x2":46,"y2":17,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":43,"y1":18,"x2":43,"y2":17,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":40,"y1":17,"x2":41,"y2":17,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":39,"y1":15,"x2":38,"y2":15,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":38,"y1":10,"x2":38,"y2":9,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":40,"y1":10,"x2":39,"y2":10,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":43,"y1":9,"x2":43,"y2":8,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":45,"y1":9,"x2":46,"y2":8,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":46,"y1":12,"x2":47,"y2":12,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":48,"y1":14,"x2":48,"y2":13,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":16,"x2":46,"y2":15,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":37,"y1":13,"x2":38,"y2":13,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":42,"y1":7,"x2":43,"y2":7,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":40,"y1":14,"x2":41,"y2":15,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":44,"y1":15,"x2":45,"y2":16,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":46,"y1":11,"x2":45,"y2":10,"color":"#ffff00","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":40,"y1":7,"x2":41,"y2":7,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":37,"y1":12,"x2":37,"y2":11,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":38,"y1":16,"x2":39,"y2":17,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":42,"y1":16,"x2":43,"y2":15,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":9,"x2":46,"y2":9,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":44,"y1":7,"x2":45,"y2":8,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":45,"y1":18,"x2":46,"y2":17,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":46,"y1":14,"x2":45,"y2":14,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":41,"y1":10,"x2":41,"y2":9,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":39,"y1":9,"x2":40,"y2":10,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":47,"y1":14,"x2":48,"y2":15,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":48,"y1":11,"x2":48,"y2":10,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":37,"y1":14,"x2":37,"y2":15,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":39,"y1":12,"x2":40,"y2":11,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":42,"y1":9,"x2":43,"y2":10,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1},{"type":"line","x1":42,"y1":18,"x2":43,"y2":17,"color":"#a19ffe","strokeWidth":0.26,"strokeOpacity":1}]} +{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","f":true},{"t":"e","p":[0,0,500,500,500,0,500,500,1000,0,500,500,1500,0,500,500,2000,0,500,500,2500,0,500,500,3000,0,500,500,3000,500,500,500,2500,500,500,500,2000,500,500,500,1500,500,500,500,1000,500,500,500,500,500,500,500,0,500,500,500,0,1000,500,500,500,1000,500,500,1000,1000,500,500,1500,1000,500,500,2000,1000,500,500,2500,1000,500,500,3000,1000,500,500,3000,1500,500,500,2500,1500,500,500,2000,1500,500,500,1500,1500,500,500,1000,1500,500,500,500,1500,500,500,0,1500,500,500,0,2000,500,500,500,2000,500,500,1000,2000,500,500,1500,2000,500,500,2000,2000,500,500,2500,2000,500,500,3000,2000,500,500]}]} {% endset %}