Ссылка
click to show
click to show
DEV Community: react
Pinning Defects on the Photo Itself: Building 360 Hotspot Mapping
This is a deep-dive into one feature of Inspection OS, a SaaS I built for property-inspection teams. It's the piece I'm most proud of, and the one with the most interesting engineering behind it. The problem: a photo is a bad way to describe where a defect is Property inspectors take hundreds of site photos. The traditional report then describes each defect in prose: "Hollowness observed in kitchen floor tiles near the north wall." The reader has to mentally map that sentence back onto the photo. Multiply that by 69 defects across 16 rooms and the report becomes a wall of text that nobody can act on quickly.
The fix seems obvious once you see it: stop describing the location in words — pin it on the image. https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs3pnt3obnwl9q083c423.png Each red dot is a defect anchored to the exact pixel where it was found. Click one and you get its severity, status, and recommended remedy. The photo becomes the interface. That's a "hotspot," and here's how the system behind it works. Design decision #1: store coordinates as fractions, not pixels The first real decision is how to store a pin's position. The naive answer is pixel coordinates — "this defect is at (1840, 920)." That breaks the moment anything about the display changes: a different screen size, a zoomed view, a thumbnail, the PDF export. Pixels are tied to one specific rendering.
So hotspots are stored as normalized coordinates in the range [0, 1]: // shared/schema.ts — the hotspots table (Drizzle + Postgres) export const hotspots = spatial.table("hotspots", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), captureId: varchar("capture_id").notNull().references(() => captures.id, { onDelete: "cascade" }), x: numeric("x", { precision: 5, scale: 4 }).notNull(), // 0.0000 – 1.0000 y: numeric("y", { precision: 5, scale: 4 }).notNull(), label: text("label").notNull(), issueSeverity: text("issue_severity"), issueStatus: text("issue_status"), notes: text("notes"), resolvedPhoto: text("resolved_photo"), // ... }); x = 0.5, y = 0.5 means dead centre of the image, whatever the image's resolution. Rendering then becomes trivial and resolution-independent — the pin is positioned as a percentage of its container: div style={{ left: `${x * 100}%`, top: `${y * 100}%` }} /> The same stored coordinate renders correctly on a retina iPad, a downscaled thumbnail, and the A4 PDF — no conversion tables, no per-device math. numeric(5,4) gives four decimal places of precision, which is sub-pixel on any realistic image. Design decision #2: inverting the pan/zoom transform on click The canvas isn't static — inspectors pan and zoom (0.3×–5×) to place pins precisely on small defects. That makes capturing the coordinate the tricky part. When the user clicks, the browser gives me a screen coordinate, but I need the coordinate in the original image's space, undoing whatever pan and zoom are currently applied.
The image is rendered with a CSS transform: transform: `translate(${panX}px, ${panY}px) scale(${scale})` So on click I apply the inverse of that transform to recover the true image-space point: const rect = containerRef.current.getBoundingClientRect(); const rx = e.clientX - rect.left; // click, relative to container const ry = e.clientY - rect.top; // undo the translate + scale to get original-image coordinates const ox = (rx - cx - panX) / scale; const oy = (ry - cy - panY) / scale; Then ox, oy get normalized against the image dimensions and stored. Get [...]