<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rahul Palivela</title>
    <description>The latest articles on DEV Community by Rahul Palivela (@rahulpalivela18).</description>
    <link>https://dev.to/rahulpalivela18</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4058043%2Fe12e48d7-552d-4767-b252-f7f7e3eef11b.jpeg</url>
      <title>DEV Community: Rahul Palivela</title>
      <link>https://dev.to/rahulpalivela18</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rahulpalivela18"/>
    <language>en</language>
    <item>
      <title>Pinning Defects on the Photo Itself: Building 360 Hotspot Mapping</title>
      <dc:creator>Rahul Palivela</dc:creator>
      <pubDate>Sat, 01 Aug 2026 14:01:35 +0000</pubDate>
      <link>https://dev.to/rahulpalivela18/pinning-defects-on-the-photo-itself-building-360deg-hotspot-mapping-3n7d</link>
      <guid>https://dev.to/rahulpalivela18/pinning-defects-on-the-photo-itself-building-360deg-hotspot-mapping-3n7d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This is a deep-dive into one feature of &lt;a href="//github.com/rahulpalivela18/inspection-os"&gt;Inspection OS&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem: a photo is a bad way to describe &lt;em&gt;where&lt;/em&gt; a defect is
&lt;/h2&gt;

&lt;p&gt;Property inspectors take hundreds of site photos. The traditional report then describes each defect in prose: &lt;em&gt;"Hollowness observed in kitchen floor tiles near the north wall."&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix seems obvious once you see it: &lt;strong&gt;stop describing the location in words — pin it on the image.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="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" class="article-body-image-wrapper"&gt;&lt;img src="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" alt="Defect hotspots pinned on a kitchen floor capture" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;becomes&lt;/em&gt; the interface. That's a "hotspot," and here's how the system behind it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision #1: store coordinates as fractions, not pixels
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So hotspots are stored as &lt;strong&gt;normalized coordinates&lt;/strong&gt; in the range &lt;code&gt;[0, 1]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/schema.ts — the hotspots table (Drizzle + Postgres)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hotspots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spatial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hotspots&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nf"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`gen_random_uuid()`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;captureId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;capture_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;captures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cascade&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// 0.0000 – 1.0000&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;issueSeverity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;issue_severity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;issueStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;issue_status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;resolvedPhoto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resolved_photo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x = 0.5, y = 0.5&lt;/code&gt; means dead centre of the image, &lt;em&gt;whatever the image's resolution.&lt;/em&gt; Rendering then becomes trivial and resolution-independent — the pin is positioned as a percentage of its container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same stored coordinate renders correctly on a retina iPad, a downscaled thumbnail, and the A4 PDF — no conversion tables, no per-device math. &lt;code&gt;numeric(5,4)&lt;/code&gt; gives four decimal places of precision, which is sub-pixel on any realistic image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision #2: inverting the pan/zoom transform on click
&lt;/h2&gt;

&lt;p&gt;The canvas isn't static — inspectors pan and zoom (0.3×–5×) to place pins precisely on small defects. That makes &lt;em&gt;capturing&lt;/em&gt; the coordinate the tricky part. When the user clicks, the browser gives me a screen coordinate, but I need the coordinate in the &lt;em&gt;original image's&lt;/em&gt; space, undoing whatever pan and zoom are currently applied.&lt;/p&gt;

&lt;p&gt;The image is rendered with a CSS transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) scale(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So on click I apply the &lt;strong&gt;inverse&lt;/strong&gt; of that transform to recover the true image-space point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// click, relative to container&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// undo the translate + scale to get original-image coordinates&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;panX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;panY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;ox, oy&lt;/code&gt; get normalized against the image dimensions and stored. Get this wrong and pins "drift" when you place them at anything other than 100% zoom — a classic and maddening bug. Getting the transform math right is what makes placement feel exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision #3: pins that don't grow when you zoom
&lt;/h2&gt;

&lt;p&gt;If the whole canvas scales by &lt;code&gt;scale&lt;/code&gt;, the pins scale with it — zoom to 5× and your dots become giant blobs that cover the very defect they mark. The fix is to &lt;strong&gt;counter-scale&lt;/strong&gt; each pin by the inverse of the zoom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translate(-50%, -50%) scale(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;translate(-50%, -50%)&lt;/code&gt; centres the dot on its coordinate; the &lt;code&gt;scale(1 / scale)&lt;/code&gt; cancels the parent's zoom so the pin stays a constant visual size at any zoom level. Small detail, big difference in how "solid" the tool feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision #4: one coordinate model for flat photos &lt;em&gt;and&lt;/em&gt; 360° panoramas
&lt;/h2&gt;

&lt;p&gt;Inspectors also shoot 360° panoramas of whole rooms. A panorama isn't a flat plane — a point on it is really a direction (pitch and yaw) on a sphere. I could have built a second, separate hotspot system for panoramas... but I didn't want to.&lt;/p&gt;

&lt;p&gt;Instead, the &lt;em&gt;same&lt;/em&gt; normalized &lt;code&gt;(x, y)&lt;/code&gt; is reinterpreted for 360° captures via a single mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pitch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;yaw&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toPitchYaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;captures.is_360&lt;/code&gt; boolean flag decides which lens to view the coordinate through. Flat image? &lt;code&gt;(x, y)&lt;/code&gt; is a position. Panorama? The same &lt;code&gt;(x, y)&lt;/code&gt; maps to a spherical direction. &lt;strong&gt;One storage format, one placement flow, two renderers.&lt;/strong&gt; The data model didn't have to know about the difference, which kept the schema — and my head — clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model: a hotspot &lt;em&gt;is&lt;/em&gt; a defect
&lt;/h2&gt;

&lt;p&gt;Notice the hotspot row carries &lt;code&gt;issueSeverity&lt;/code&gt;, &lt;code&gt;issueStatus&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, and &lt;code&gt;resolvedPhoto&lt;/code&gt;. A hotspot isn't just a marker that points at a separate "issue" record — it &lt;strong&gt;is&lt;/strong&gt; the defect, anchored in space. This is a deliberate denormalization: the issue's display fields live on the hotspot so the canvas can render every pin (with its severity colour) in a single query, without joining and re-fetching issue records for each dot. For a view that paints dozens of pins at once, that read-path simplicity is worth the redundancy.&lt;/p&gt;

&lt;p&gt;Everything cascades on delete — remove a capture and its hotspots go with it (&lt;code&gt;onDelete: "cascade"&lt;/code&gt;), so there are no orphaned pins pointing at images that no longer exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently / open questions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precision vs. storage:&lt;/strong&gt; &lt;code&gt;numeric(5,4)&lt;/code&gt; is precise but heavier than a &lt;code&gt;real&lt;/code&gt;. At current scale it's irrelevant; at millions of hotspots I'd benchmark it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denormalized issue fields&lt;/strong&gt; speed up reads but mean an issue edit has to update the hotspot too. A single-writer inspection flow makes this safe today; a collaborative one would need more care.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;360° math&lt;/strong&gt; currently assumes an equirectangular projection. Other panorama formats would need their own &lt;code&gt;toPitchYaw&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Future work: the dataset nobody planned for
&lt;/h2&gt;

&lt;p&gt;Here's the part I find most interesting, and where I want to take this next.&lt;/p&gt;

&lt;p&gt;Every hotspot an inspector places is a &lt;strong&gt;human-labeled training example&lt;/strong&gt;: an image, a precise &lt;code&gt;(x, y)&lt;/code&gt; location, a defect category, and a severity — &lt;em&gt;"at this spot, there is a &lt;em&gt;major&lt;/em&gt; tile hollowness."&lt;/em&gt; The app has been quietly accumulating a labeled, spatially-grounded defect dataset as a byproduct of normal use.&lt;/p&gt;

&lt;p&gt;That opens a genuine research direction: &lt;strong&gt;can a computer-vision model learn to propose hotspots automatically?&lt;/strong&gt; Given a fresh site photo, suggest &lt;em&gt;"likely crack here, likely dampness there,"&lt;/em&gt; and let the inspector confirm or reject — turning placement from fully-manual into human-in-the-loop verification. The normalized-coordinate design already gives model output and human labels the exact same representation, so predictions and ground truth are directly comparable.&lt;/p&gt;

&lt;p&gt;There are hard problems in the way — class imbalance (some defects are rare), domain shift across sites and lighting, and the fact that "hollowness" isn't visually obvious at all (it's found by &lt;em&gt;tapping&lt;/em&gt;, not looking, which is a fascinating limit of a vision-only approach). But the data is real, the labels are honest, and the representation is already right. That's the thread I want to pull on next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Inspection OS is built with React 19, Express, and PostgreSQL (Drizzle ORM). If the hotspot design or the CV direction interests you, the code is on &lt;a href="//github.com/rahulpalivela18/inspection-os"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
