Add grid image system with multi-panel support and SPA routing

- Grid images: photograph component boxes in sub-sections, assemble
  into one logical grid via perspective warp (homography)
- Source image gallery: bulk upload photos separately from grid setup
- Grid drafts: persist partial work, resume across sessions
- Multi-panel grids: define rows/cols per photo, system computes panel
  layout; process partially configured grids, edit individual panels
- Pan/zoom canvas editor (HiDPI-aware, touch support) for corner alignment
- SPA routing with canonical URLs (history.pushState, server catch-all)
- Express error visibility: uncaughtException/unhandledRejection handlers
  and 4-arg error middleware
- Original filename stored on source image upload
- Various null-safety fixes and CSS [hidden] override fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:30:17 +00:00
parent ef2e53ea18
commit cf37759893
11 changed files with 2835 additions and 250 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Electronics Inventory</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="/style.css">
<link rel="preload" as="fetch" href="/templates.html" crossorigin>
</head>
<body>
@@ -14,9 +14,10 @@
<button class="nav-btn active" data-section="components">Components</button>
<button class="nav-btn" data-section="inventory">Inventory</button>
<button class="nav-btn" data-section="fields">Fields</button>
<button class="nav-btn" data-section="grids">Grids</button>
</nav>
</header>
<main id="main"></main>
<script type="module" src="app.mjs"></script>
<script type="module" src="/app.mjs"></script>
</body>
</html>

View File

@@ -27,3 +27,19 @@ export const get_inventory = () => req('GET', '/api/inventory');
export const create_inventory = (body) => req('POST', '/api/inventory', body);
export const update_inventory = (id, body) => req('PUT', `/api/inventory/${id}`, body);
export const delete_inventory = (id) => req('DELETE', `/api/inventory/${id}`);
// Grid drafts
export const get_grid_drafts = () => req('GET', '/api/grid-drafts');
export const create_grid_draft = (body) => req('POST', '/api/grid-drafts', body);
export const update_grid_draft = (id, body) => req('PUT', `/api/grid-drafts/${id}`, body);
export const delete_grid_draft = (id) => req('DELETE', `/api/grid-drafts/${id}`);
// Source images
export const get_source_images = () => req('GET', '/api/source-images');
export const delete_source_image = (id) => req('DELETE', `/api/source-images/${id}`);
// Grid images
export const get_grids = () => req('GET', '/api/grid-images');
export const get_grid = (id) => req('GET', `/api/grid-images/${id}`);
export const delete_grid = (id) => req('DELETE', `/api/grid-images/${id}`);
export const update_grid_panel = (id, pi, body) => req('PUT', `/api/grid-images/${id}/panels/${pi}`, body);

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,21 @@
<!-- ===== COMPONENTS SECTION ===== -->
<template id="t-section-components">
<section class="section" id="section-components">
<div class="section-toolbar">
<input type="search" id="component-search" class="search-input" placeholder="Search components…">
<button class="btn btn-primary" id="btn-add-component">+ Add component</button>
<div class="split-layout">
<div class="list-pane">
<input type="search" id="component-search" class="search-input" placeholder="Search…">
<input type="text" id="quick-add" class="quick-add-input" placeholder="New component… ↵" autocomplete="off" spellcheck="false">
<div id="component-list" class="component-list"></div>
</div>
<div class="detail-pane" id="detail-pane"></div>
</div>
<div id="component-list" class="item-list"></div>
</section>
</template>
<template id="t-component-row">
<div class="component-row">
<div class="component-main">
<div class="component-header">
<span class="component-name"></span>
<span class="component-description"></span>
</div>
<div class="component-fields"></div>
<div class="component-locations"></div>
</div>
<div class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</div>
<div class="component-row" tabindex="0">
<div class="component-name"></div>
<div class="component-tags"></div>
</div>
</template>
@@ -30,8 +23,86 @@
<span class="field-tag"><span class="tag-name"></span><span class="tag-value"></span></span>
</template>
<template id="t-location-badge">
<span class="location-badge"><span class="badge-icon"></span><span class="badge-text"></span></span>
<!-- ===== DETAIL PANEL ===== -->
<template id="t-detail-placeholder">
<div class="detail-placeholder">Select a component to view details</div>
</template>
<template id="t-detail-content">
<div class="detail-content">
<div class="detail-header">
<div>
<h2 class="detail-name"></h2>
<p class="detail-description"></p>
</div>
<div class="detail-header-actions">
<button class="btn btn-secondary detail-edit-btn">Edit</button>
<button class="btn btn-danger detail-delete-btn">Delete</button>
</div>
</div>
<div class="detail-block">
<div class="detail-block-label">Fields</div>
<div class="detail-fields-list"></div>
</div>
<div class="detail-block">
<div class="detail-block-label">Images</div>
<div class="detail-images-row">
<div class="image-grid comp-image-grid"></div>
<label class="btn-add-image">
<input type="file" accept="image/*" multiple hidden class="comp-img-input">
+ Add image
</label>
</div>
</div>
<div class="detail-block">
<div class="detail-block-label">
Inventory
<button class="btn btn-secondary detail-add-inv-btn">+ Add entry</button>
</div>
<div class="detail-inventory-list"></div>
</div>
</div>
</template>
<template id="t-detail-field-row">
<div class="detail-field-row">
<span class="detail-field-name"></span>
<span class="detail-field-value"></span>
</div>
</template>
<template id="t-detail-inv-entry">
<div class="detail-inv-entry">
<div class="detail-inv-header">
<span class="detail-inv-type"></span>
<span class="detail-inv-ref"></span>
<span class="detail-inv-qty"></span>
<span class="detail-inv-notes"></span>
<span class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
<div class="detail-inv-images">
<div class="image-grid inv-image-grid"></div>
<label class="btn-add-image btn-add-image-sm">
<input type="file" accept="image/*" multiple hidden class="inv-img-input">
+ Image
</label>
</div>
</div>
</template>
<template id="t-image-thumb">
<div class="image-thumb">
<a class="thumb-link" target="_blank" rel="noopener">
<img class="thumb-img" alt="">
</a>
<button type="button" class="thumb-delete btn-icon btn-danger" title="Remove"></button>
</div>
</template>
<!-- ===== INVENTORY SECTION ===== -->
@@ -47,30 +118,32 @@
</select>
<button class="btn btn-primary" id="btn-add-inventory">+ Add entry</button>
</div>
<div class="table-header inventory-grid">
<span>Component</span>
<span>Type</span>
<span>Location / Reference</span>
<span>Qty</span>
<span>Notes</span>
<span></span>
</div>
<div id="inventory-list" class="item-list"></div>
<table class="data-table">
<thead><tr>
<th>Component</th>
<th class="col-type">Type</th>
<th>Location / Reference</th>
<th class="col-qty">Qty</th>
<th>Notes</th>
<th class="col-actions"></th>
</tr></thead>
<tbody id="inventory-list"></tbody>
</table>
</section>
</template>
<template id="t-inventory-row">
<div class="inventory-row inventory-grid">
<span class="inv-component-name"></span>
<span class="inv-type-badge"></span>
<span class="inv-location-ref"></span>
<span class="inv-quantity"></span>
<span class="inv-notes"></span>
<span class="row-actions">
<tr class="data-row">
<td class="inv-component-name"></td>
<td class="inv-type-badge"></td>
<td class="inv-location-ref"></td>
<td class="inv-quantity"></td>
<td class="inv-notes"></td>
<td class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</div>
</td>
</tr>
</template>
<!-- ===== FIELDS SECTION ===== -->
@@ -80,25 +153,169 @@
<span class="section-note">Master field index — fields available for all components</span>
<button class="btn btn-primary" id="btn-add-field">+ Add field</button>
</div>
<div class="table-header fields-grid">
<span>Field name</span>
<span>Unit</span>
<span>Description</span>
<span></span>
</div>
<div id="field-list" class="item-list"></div>
<table class="data-table">
<thead><tr>
<th>Field name</th>
<th class="col-unit">Unit</th>
<th>Description</th>
<th class="col-actions"></th>
</tr></thead>
<tbody id="field-list"></tbody>
</table>
</section>
</template>
<template id="t-field-row">
<div class="field-def-row fields-grid">
<span class="fdef-name"></span>
<span class="fdef-unit"></span>
<span class="fdef-description"></span>
<span class="row-actions">
<tr class="data-row">
<td class="fdef-name"></td>
<td class="fdef-unit"></td>
<td class="fdef-description"></td>
<td class="row-actions">
<button class="btn-icon btn-edit" title="Edit"></button>
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</span>
</td>
</tr>
</template>
<!-- ===== EMPTY STATES ===== -->
<template id="t-empty-row">
<tr><td class="empty-state"></td></tr>
</template>
<template id="t-empty-block">
<div class="empty-state"></div>
</template>
<!-- ===== GRIDS SECTION ===== -->
<template id="t-section-grids">
<section class="section" id="section-grids">
<div class="section-toolbar">
<div class="tab-bar">
<button class="tab-btn" id="btn-tab-grids">Grids</button>
<button class="tab-btn" id="btn-tab-sources">Source images</button>
</div>
<button class="btn btn-primary" id="btn-new-grid">+ New grid</button>
<label class="btn btn-secondary" id="btn-upload-sources">
+ Upload
<input type="file" accept="image/*" multiple hidden id="source-upload-input">
</label>
</div>
<div id="tab-grids-content">
<div id="grid-list" class="grid-card-list"></div>
</div>
<div id="tab-sources-content" hidden>
<div id="source-image-list" class="source-gallery"></div>
</div>
</section>
</template>
<template id="t-source-card">
<div class="source-card">
<a class="source-card-link" target="_blank" rel="noopener">
<img class="source-card-img" alt="">
</a>
<div class="source-card-meta"></div>
<button type="button" class="btn-icon btn-danger source-card-delete" title="Delete"></button>
</div>
</template>
<template id="t-draft-card">
<div class="draft-card">
<div class="draft-badge">Draft</div>
<div class="draft-card-info">
<div class="draft-card-name"></div>
<div class="draft-card-meta"></div>
</div>
<div class="row-actions">
<button class="btn-icon btn-danger btn-delete" title="Discard"></button>
</div>
</div>
</template>
<template id="t-grid-card">
<div class="grid-card">
<div class="grid-card-preview"></div>
<div class="grid-card-info">
<div class="grid-card-name"></div>
<div class="grid-card-meta"></div>
</div>
<div class="row-actions">
<button class="btn-icon btn-danger btn-delete" title="Delete"></button>
</div>
</div>
</template>
<!-- ===== PANEL MANAGER VIEW ===== -->
<template id="t-panel-manager">
<div class="panel-manager" id="panel-manager">
<div class="panel-manager-header">
<div>
<h2 class="pm-name"></h2>
<div class="pm-meta"></div>
</div>
<div class="pm-actions">
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
<button class="btn btn-primary" id="pm-process" disabled>Process all panels</button>
</div>
</div>
<div class="panel-slot-grid" id="panel-slot-grid"></div>
<div class="setup-progress" id="pm-progress" hidden>Processing panels…</div>
</div>
</template>
<template id="t-panel-slot">
<div class="panel-slot" tabindex="0">
<div class="panel-slot-preview">
<img class="panel-slot-thumb" alt="" hidden>
<div class="panel-slot-empty-icon"></div>
</div>
<div class="panel-slot-label"></div>
<div class="panel-slot-range"></div>
</div>
</template>
<!-- ===== GRID SETUP VIEW ===== -->
<template id="t-grid-setup">
<div class="grid-setup" id="grid-setup">
<div class="grid-setup-left">
<canvas id="grid-canvas" class="grid-canvas"></canvas>
<div class="grid-setup-hint">Scroll to zoom · drag to pan · drag handles to align</div>
</div>
<div class="grid-setup-right">
<div class="gs-panel-info" id="gs-panel-info"></div>
<div class="setup-cell-size" id="gs-cell-size"></div>
<div class="setup-actions">
<button class="btn btn-secondary" id="gs-cancel">← Back</button>
<button class="btn btn-primary" id="gs-confirm">Confirm panel</button>
</div>
<div class="setup-progress" id="gs-progress" hidden></div>
</div>
</div>
</template>
<!-- ===== GRID VIEWER ===== -->
<template id="t-grid-viewer">
<div class="grid-viewer" id="grid-viewer">
<div class="grid-viewer-header">
<div>
<h2 class="viewer-name"></h2>
<div class="viewer-meta"></div>
</div>
<div class="grid-viewer-actions">
<button class="btn btn-secondary" id="gv-back">← Back</button>
<button class="btn btn-secondary" id="gv-edit-panels">Edit panels</button>
<button class="btn btn-danger" id="gv-delete">Delete</button>
</div>
</div>
<div class="grid-cells" id="grid-cells"></div>
</div>
</template>
<template id="t-grid-cell">
<div class="grid-cell">
<div class="grid-cell-img-wrap">
<img class="grid-cell-img" alt="">
</div>
<div class="grid-cell-label"></div>
</div>
</template>
@@ -194,6 +411,63 @@
</dialog>
</template>
<!-- ===== DIALOG: NEW GRID ===== -->
<template id="t-dialog-new-grid">
<dialog id="dialog-new-grid" class="app-dialog">
<h2 class="dialog-title">New grid</h2>
<form method="dialog" id="form-new-grid">
<div class="form-row">
<label for="ng-name">Name</label>
<input type="text" id="ng-name" autocomplete="off" placeholder="e.g. Capacitor box A">
</div>
<div class="form-row-pair">
<div class="form-row">
<label for="ng-rows">Total rows</label>
<input type="number" id="ng-rows" min="1" max="500" value="4">
</div>
<div class="form-row">
<label for="ng-cols">Total columns</label>
<input type="number" id="ng-cols" min="1" max="500" value="6">
</div>
</div>
<div class="form-section-label">Photo coverage <span class="label-hint">(cells visible in each photo)</span></div>
<div class="form-row-pair">
<div class="form-row">
<label for="ng-photo-rows">Rows per photo</label>
<input type="number" id="ng-photo-rows" min="1" max="500" value="4">
</div>
<div class="form-row">
<label for="ng-photo-cols">Cols per photo</label>
<input type="number" id="ng-photo-cols" min="1" max="500" value="6">
</div>
</div>
<div class="ng-summary" id="ng-summary"></div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="ng-cancel">Cancel</button>
<button type="submit" class="btn btn-primary">Continue →</button>
</div>
</form>
</dialog>
</template>
<!-- ===== DIALOG: SOURCE PICKER ===== -->
<template id="t-dialog-source-picker">
<dialog id="dialog-source-picker" class="app-dialog app-dialog-wide">
<h2 class="dialog-title">Choose source image</h2>
<div class="picker-toolbar">
<label class="btn btn-secondary">
+ Upload new
<input type="file" accept="image/*" multiple hidden id="picker-upload-input">
</label>
<span class="picker-hint">or select an existing image below</span>
</div>
<div id="source-picker-grid" class="source-gallery picker-gallery"></div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="picker-cancel">Cancel</button>
</div>
</dialog>
</template>
<!-- ===== DIALOG: CONFIRM ===== -->
<template id="t-dialog-confirm">
<dialog id="dialog-confirm" class="app-dialog app-dialog-sm">

263
public/views/grid-setup.mjs Normal file
View File

@@ -0,0 +1,263 @@
// Grid setup canvas UI — handles corner selection, pan, and zoom
export class Grid_Setup {
#canvas;
#ctx;
#img = null;
#scale = 1; // image px → world px (CSS px at cam_z=1)
#css_w = 0;
#css_h = 0;
// Camera: world → screen (screen = world * cam_z + cam_offset)
#cam_x = 0;
#cam_y = 0;
#cam_z = 1;
#corners = null; // in IMAGE coordinates
#drag_idx = -1; // index of corner being dragged, or -1
#panning = false;
#pan_last = { x: 0, y: 0 };
#rows = 4;
#cols = 6;
constructor(canvas_el) {
this.#canvas = canvas_el;
this.#ctx = canvas_el.getContext('2d');
canvas_el.addEventListener('mousedown', e => this.#on_down(e));
canvas_el.addEventListener('mousemove', e => this.#on_move(e));
canvas_el.addEventListener('mouseup', e => this.#on_up(e));
canvas_el.addEventListener('mouseleave', () => { this.#drag_idx = -1; this.#panning = false; });
canvas_el.addEventListener('wheel', e => this.#on_wheel(e), { passive: false });
canvas_el.addEventListener('touchstart', e => { e.preventDefault(); this.#on_down(e.touches[0], true); }, { passive: false });
canvas_el.addEventListener('touchmove', e => { e.preventDefault(); this.#on_move(e.touches[0], true); }, { passive: false });
canvas_el.addEventListener('touchend', () => { this.#drag_idx = -1; this.#panning = false; });
}
load_image(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this.#img = img;
const max_w = this.#canvas.parentElement.clientWidth || 800;
const max_h = Math.floor(window.innerHeight * 0.65);
// Canvas fills the available space
this.#css_w = max_w;
this.#css_h = max_h;
// Scale: fit image within canvas with slight padding
this.#scale = Math.min(
(max_w * 0.9) / img.width,
(max_h * 0.9) / img.height,
);
const dpr = window.devicePixelRatio || 1;
this.#canvas.width = this.#css_w * dpr;
this.#canvas.height = this.#css_h * dpr;
this.#canvas.style.width = this.#css_w + 'px';
this.#canvas.style.height = this.#css_h + 'px';
this.#ctx.scale(dpr, dpr);
// Camera: start centered, image fitted within canvas
const img_w = img.width * this.#scale;
const img_h = img.height * this.#scale;
this.#cam_z = 1;
this.#cam_x = (max_w - img_w) / 2;
this.#cam_y = (max_h - img_h) / 2;
// Default corners: 15% inset in image coords
const mx = img.width * 0.15;
const my = img.height * 0.15;
this.#corners = [
{ x: mx, y: my }, // TL
{ x: img.width - mx, y: my }, // TR
{ x: img.width - mx, y: img.height - my }, // BR
{ x: mx, y: img.height - my }, // BL
];
this.#draw();
resolve({ width: img.width, height: img.height });
};
img.onerror = reject;
img.src = url;
});
}
set_rows(n) { this.#rows = n; this.#draw(); }
set_cols(n) { this.#cols = n; this.#draw(); }
set_corners(corners) {
if (!corners || corners.length !== 4) return;
this.#corners = corners.map(c => ({ x: c.x, y: c.y }));
this.#draw();
}
get_corners() { return this.#corners?.map(c => ({ x: Math.round(c.x), y: Math.round(c.y) })); }
// Raw CSS pixel position on canvas
#screen_pos(e) {
const r = this.#canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
// Screen → world (CSS px at cam_z=1, i.e. the coordinate space image is drawn in)
#to_world(sp) {
return {
x: (sp.x - this.#cam_x) / this.#cam_z,
y: (sp.y - this.#cam_y) / this.#cam_z,
};
}
// Image coords → world coords
#img_to_world(pt) {
return { x: pt.x * this.#scale, y: pt.y * this.#scale };
}
// World coords → image coords
#world_to_img(pt) {
return { x: pt.x / this.#scale, y: pt.y / this.#scale };
}
// Image coords → screen coords (for hit testing)
#img_to_screen(pt) {
const w = this.#img_to_world(pt);
return { x: w.x * this.#cam_z + this.#cam_x, y: w.y * this.#cam_z + this.#cam_y };
}
#find_handle(sp, radius = 18) {
if (!this.#corners) return -1;
for (let i = 0; i < 4; i++) {
const s = this.#img_to_screen(this.#corners[i]);
if ((sp.x - s.x)**2 + (sp.y - s.y)**2 < radius**2) return i;
}
return -1;
}
#on_down(e, is_touch = false) {
if (!this.#corners) return;
const sp = this.#screen_pos(e);
const hit = this.#find_handle(sp);
if (hit !== -1) {
this.#drag_idx = hit;
} else {
this.#panning = true;
this.#pan_last = sp;
if (!is_touch) this.#canvas.style.cursor = 'grabbing';
}
}
#on_move(e, is_touch = false) {
const sp = this.#screen_pos(e);
if (this.#drag_idx !== -1) {
const img_pos = this.#world_to_img(this.#to_world(sp));
this.#corners[this.#drag_idx] = {
x: Math.max(0, Math.min(this.#img.width, img_pos.x)),
y: Math.max(0, Math.min(this.#img.height, img_pos.y)),
};
this.#draw();
} else if (this.#panning) {
this.#cam_x += sp.x - this.#pan_last.x;
this.#cam_y += sp.y - this.#pan_last.y;
this.#pan_last = sp;
this.#draw();
} else if (!is_touch) {
const cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default';
this.#canvas.style.cursor = cursor;
}
}
#on_up(e) {
this.#drag_idx = -1;
if (this.#panning) {
this.#panning = false;
const sp = this.#screen_pos(e);
this.#canvas.style.cursor = this.#find_handle(sp) !== -1 ? 'grab' : 'default';
}
}
#on_wheel(e) {
e.preventDefault();
const sp = this.#screen_pos(e);
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const wx = (sp.x - this.#cam_x) / this.#cam_z;
const wy = (sp.y - this.#cam_y) / this.#cam_z;
this.#cam_z = Math.max(0.1, Math.min(20, this.#cam_z * factor));
this.#cam_x = sp.x - wx * this.#cam_z;
this.#cam_y = sp.y - wy * this.#cam_z;
this.#draw();
}
#draw() {
const ctx = this.#ctx;
if (!this.#img || !this.#corners) return;
const W = this.#css_w, H = this.#css_h;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0e0e0e';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.translate(this.#cam_x, this.#cam_y);
ctx.scale(this.#cam_z, this.#cam_z);
// Image drawn in world space at its scaled dimensions
const img_w = this.#img.width * this.#scale;
const img_h = this.#img.height * this.#scale;
ctx.drawImage(this.#img, 0, 0, img_w, img_h);
// Everything below is in world coords (image coords × #scale)
const dp = this.#corners.map(c => this.#img_to_world(c));
const rows = this.#rows, cols = this.#cols;
// Outline
ctx.beginPath();
ctx.moveTo(dp[0].x, dp[0].y);
dp.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.strokeStyle = 'rgba(91,156,246,0.9)';
ctx.lineWidth = 2 / this.#cam_z;
ctx.stroke();
// Grid lines
function lerp(a, b, t) { return { x: a.x + (b.x-a.x)*t, y: a.y + (b.y-a.y)*t }; }
ctx.strokeStyle = 'rgba(91,156,246,0.45)';
ctx.lineWidth = 1 / this.#cam_z;
for (let r = 1; r < rows; r++) {
const t = r / rows;
const a = lerp(dp[0], dp[3], t), b = lerp(dp[1], dp[2], t);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
for (let c = 1; c < cols; c++) {
const t = c / cols;
const a = lerp(dp[0], dp[1], t), b = lerp(dp[3], dp[2], t);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
// Corner handles — fixed screen size regardless of zoom
const handle_r = 10 / this.#cam_z;
const font_size = Math.round(9 / this.#cam_z);
const COLORS = ['#f6a65b', '#f65b9c', '#5bf69c', '#5b9cf6'];
const LABELS = ['TL', 'TR', 'BR', 'BL'];
dp.forEach((pt, i) => {
ctx.beginPath();
ctx.arc(pt.x, pt.y, handle_r, 0, Math.PI*2);
ctx.fillStyle = COLORS[i];
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 2 / this.#cam_z;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = `bold ${font_size}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(LABELS[i], pt.x, pt.y);
});
ctx.restore();
}
}