← back to NationalPaperHangers

public/js/room-capture.js

341 lines

/* Room capture & measure — /book quote brief (UX #6).
 *
 * Per room: photograph a wall, drag a box to frame it, confirm ceiling
 * height -> wall width is derived from the box aspect, optionally upload
 * the customer's own wallpaper (exact panel width + vertical repeat) to
 * preview it tiled on the wall at TRUE scale. Everything serializes into
 * the hidden `room_captures` field that calendar-consumer.js posts with
 * the booking, so the installer quotes from real footage + dimensions.
 *
 * The box's on-screen aspect ratio equals the wall's real aspect ratio
 * when the wall is shot roughly fronto-parallel (the "~4 ft back, phone
 * level" guidance) — so width = confirmed-height x box-aspect. It's an
 * estimate the customer then confirms, never a silent measurement.
 */
(function () {
  var root = document.querySelector('[data-room-capture]');
  if (!root) return;
  var tpl = document.getElementById('rc-room-tpl');
  var roomsEl = root.querySelector('[data-rc-rooms]');
  var field = root.querySelector('[data-rc-field]');
  var addBtn = root.querySelector('[data-rc-add]');
  var sqftInput = document.querySelector('input[name="square_feet"]');
  var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';

  var rooms = [];
  // Once the customer types their own square-feet figure, stop overwriting it
  // with the measured-walls total — a programmatic .value set never fires
  // 'input', so this flag only trips on a genuine keystroke.
  var userEditedSqft = false;
  if (sqftInput) {
    sqftInput.addEventListener('input', function () { userEditedSqft = true; });
  }

  function num(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
  function round(n, step) { return Math.round(n / step) * step; }

  function uploadFile(file) {
    var fd = new FormData();
    fd.append('file', file);
    return fetch('/api/booking-media', {
      method: 'POST',
      headers: csrf ? { 'X-CSRF-Token': csrf } : {},
      body: fd
    }).then(function (r) { return r.json(); }).then(function (j) {
      if (!j || !j.ok) throw new Error((j && j.error) || 'Upload failed');
      return j;
    });
  }

  // Collect every room with a photo into the hidden field, and total the
  // measured walls into the manual square-feet input (customer can override).
  function serialize() {
    var out = [], totalSqFt = 0;
    rooms.forEach(function (r) {
      if (!r.photoUrl) return;
      var w = num(r.el.querySelector('.rc-width').value);
      var h = num(r.el.querySelector('.rc-height').value);
      if (w > 0 && h > 0) totalSqFt += w * h;
      var entry = {
        room: r.el.querySelector('.rc-room-label').value.trim() || 'Room',
        wall_width_ft: w || null,
        wall_height_ft: h || null,
        photo_url: r.photoUrl,
        video_url: r.videoUrl || null,
        wallpaper: null
      };
      var wpW = num(r.el.querySelector('.rc-wp-width').value);
      var wpR = num(r.el.querySelector('.rc-wp-repeat').value);
      if (r.wallpaperUrl && wpW > 0 && wpR > 0) {
        entry.wallpaper = {
          image_url: r.wallpaperUrl,
          width_in: wpW,
          repeat_in: wpR,
          match_type: r.el.querySelector('.rc-wp-match').value
        };
      }
      out.push(entry);
    });
    field.value = JSON.stringify(out);
    if (sqftInput && totalSqFt > 0 && !userEditedSqft) {
      sqftInput.value = Math.round(totalSqFt);
    }
  }

  function applyBox(r) {
    var el = r.el.querySelector('.rc-box');
    el.style.left = (r.box.l * 100) + '%';
    el.style.top = (r.box.t * 100) + '%';
    el.style.width = (r.box.w * 100) + '%';
    el.style.height = (r.box.h * 100) + '%';
  }

  function recompute(r) {
    var wrap = r.el.querySelector('.rc-photo-wrap').getBoundingClientRect();
    if (!wrap.width || !wrap.height) return;
    var boxAspect = (r.box.w * wrap.width) / (r.box.h * wrap.height);
    var h = num(r.el.querySelector('.rc-height').value);
    if (h > 0 && boxAspect > 0) {
      var w = round(h * boxAspect, 0.5);
      r.el.querySelector('.rc-width').value = w;
      r.el.querySelector('.rc-estimate').textContent =
        'Estimated ' + w + ' ft wide x ' + h + ' ft tall (' +
        Math.round(w * h) + ' sq ft) — confirm or adjust.';
    }
    renderWallpaper(r);
    serialize();
  }

  // Build a half-drop "super-tile": two panels wide, the right column
  // dropped by half the vertical repeat (with a wrap copy so it tiles
  // seamlessly). Repeated straight, this super-tile reproduces the
  // half-drop match. Returns a data: URL (allowed by the page CSP).
  function buildHalfDrop(img) {
    var w = img.naturalWidth || 600, h = img.naturalHeight || 600;
    var scale = Math.min(1, 1400 / w); // cap so the data URL stays small
    var tw = Math.max(1, Math.round(w * scale));
    var th = Math.max(1, Math.round(h * scale));
    var c = document.createElement('canvas');
    c.width = tw * 2;
    c.height = th;
    var ctx = c.getContext('2d');
    ctx.drawImage(img, 0, 0, tw, th);              // left column — aligned
    ctx.drawImage(img, tw, th / 2, tw, th);        // right column — dropped ½
    ctx.drawImage(img, tw, th / 2 - th, tw, th);   // …wrap copy of the drop
    return c.toDataURL('image/png');
  }

  // Tile the uploaded wallpaper onto the wall box at real-world scale:
  // one panel spans (width_in/12 / wall_width_ft) of the box width; one
  // vertical repeat spans (repeat_in/12 / wall_height_ft) of its height.
  // Half-drop swaps in the super-tile (2 panels wide) at double the width.
  function renderWallpaper(r) {
    var layer = r.el.querySelector('.rc-wallpaper-layer');
    var note = r.el.querySelector('.rc-wp-note');
    var wpW = num(r.el.querySelector('.rc-wp-width').value);
    var wpR = num(r.el.querySelector('.rc-wp-repeat').value);
    var wallW = num(r.el.querySelector('.rc-width').value);
    var wallH = num(r.el.querySelector('.rc-height').value);
    if (!r.wallpaperUrl) { layer.style.backgroundImage = ''; return; }
    if (!(wpW > 0) || !(wpR > 0)) {
      layer.style.backgroundImage = '';
      note.classList.add('is-error');
      note.textContent = 'Enter the exact panel width and vertical repeat to preview at true scale.';
      return;
    }
    var match = r.el.querySelector('.rc-wp-match').value;
    note.classList.remove('is-error');
    note.textContent = 'Previewed at true scale — ' + wpW + '" panel, ' + wpR + '" repeat, '
      + (match === 'half_drop' ? 'half-drop' : 'straight') + ' match.';
    if (!(wallW > 0) || !(wallH > 0)) { layer.style.backgroundImage = ''; return; }
    var tileWpct = ((wpW / 12) / wallW) * 100;
    var tileHpct = ((wpR / 12) / wallH) * 100;
    if (match === 'half_drop' && r.wpImgReady) {
      if (!r.halfDropUrl) r.halfDropUrl = buildHalfDrop(r.wpImg);
      layer.style.backgroundImage = 'url("' + r.halfDropUrl + '")';
      layer.style.backgroundSize = (tileWpct * 2).toFixed(2) + '% ' + tileHpct.toFixed(2) + '%';
    } else {
      // straight match — or half-drop while the image is still loading
      layer.style.backgroundImage = 'url("' + r.wallpaperUrl + '")';
      layer.style.backgroundSize = tileWpct.toFixed(2) + '% ' + tileHpct.toFixed(2) + '%';
    }
  }

  // Corner-drag — axis-aligned rect, opposite corner stays anchored. The
  // move/up listeners live on `window`, so the drag survives the pointer
  // leaving the small handle (mouse and touch alike); `touch-action:none`
  // on the photo + handles stops a touch-drag from scrolling the page.
  function wireBox(r) {
    var wrap = r.el.querySelector('.rc-photo-wrap');
    r.el.querySelectorAll('.rc-handle').forEach(function (handle) {
      var corner = handle.getAttribute('data-corner');
      handle.addEventListener('pointerdown', function (e) {
        e.preventDefault();
        function move(ev) {
          var rect = wrap.getBoundingClientRect();
          if (!rect.width || !rect.height) return;
          var x = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
          var y = Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height));
          var b = r.box, right = b.l + b.w, bottom = b.t + b.h, MIN = 0.08;
          if (corner === 'tl') { b.l = Math.min(x, right - MIN); b.t = Math.min(y, bottom - MIN); b.w = right - b.l; b.h = bottom - b.t; }
          else if (corner === 'tr') { b.t = Math.min(y, bottom - MIN); b.w = Math.max(MIN, x - b.l); b.h = bottom - b.t; }
          else if (corner === 'bl') { b.l = Math.min(x, right - MIN); b.w = right - b.l; b.h = Math.max(MIN, y - b.t); }
          else { b.w = Math.max(MIN, x - b.l); b.h = Math.max(MIN, y - b.t); }
          applyBox(r);
          recompute(r);
        }
        function up() {
          window.removeEventListener('pointermove', move);
          window.removeEventListener('pointerup', up);
          window.removeEventListener('pointercancel', up);
        }
        window.addEventListener('pointermove', move);
        window.addEventListener('pointerup', up);
        window.addEventListener('pointercancel', up);
      });
    });
  }

  function initBox(r) {
    r.box = { l: 0.15, t: 0.10, w: 0.70, h: 0.80 };
    applyBox(r);
    recompute(r);
  }

  function wireRoom(r) {
    var el = r.el;

    el.querySelector('.rc-remove').addEventListener('click', function () {
      rooms = rooms.filter(function (x) { return x !== r; });
      el.remove();
      serialize();
    });

    var photoInput = el.querySelector('.rc-photo-input');
    var uploading = el.querySelector('.rc-uploading');
    photoInput.addEventListener('change', function () {
      var f = photoInput.files[0];
      if (!f) return;
      uploading.hidden = false;
      uploading.classList.remove('is-error');
      uploading.textContent = 'Uploading photo…';
      // Display the stored URL (same-origin) rather than a blob: object URL —
      // the page CSP is `img-src 'self' data: https:`, which excludes blob:.
      uploadFile(f).then(function (j) {
        r.photoUrl = j.url;
        var img = el.querySelector('.rc-photo');
        img.onload = function () { initBox(r); };
        img.src = j.url;
        uploading.hidden = true;
        el.querySelector('.rc-stage').hidden = false;
        el.querySelector('.rc-photo-cta').textContent = '📷 Retake wall photo';
        if (img.complete && img.naturalWidth) initBox(r);
        serialize();
      }).catch(function (err) {
        uploading.classList.add('is-error');
        uploading.textContent = (err && err.message) || 'Upload failed — try again.';
      });
    });

    var videoInput = el.querySelector('.rc-video-input');
    videoInput.addEventListener('change', function () {
      var f = videoInput.files[0];
      if (!f) return;
      var cta = el.querySelector('.rc-video-cta');
      cta.textContent = 'Uploading video…';
      uploadFile(f).then(function (j) {
        r.videoUrl = j.url;
        cta.textContent = '🎬 Walkthrough added ✓ — replace';
        serialize();
      }).catch(function () { cta.textContent = '🎬 Upload failed — tap to retry'; });
    });

    var wpInput = el.querySelector('.rc-wp-input');
    wpInput.addEventListener('change', function () {
      var f = wpInput.files[0];
      if (!f) return;
      var cta = el.querySelector('.rc-wp-cta');
      cta.textContent = 'Uploading…';
      uploadFile(f).then(function (j) {
        r.wallpaperUrl = j.url;
        // Load the image into an <img> too — needed to composite the
        // half-drop super-tile on a canvas. Same-origin, so not tainted.
        r.halfDropUrl = null;
        r.wpImgReady = false;
        r.wpImg = new Image();
        r.wpImg.onload = function () { r.wpImgReady = true; r.halfDropUrl = null; renderWallpaper(r); };
        r.wpImg.src = j.url;
        cta.textContent = 'Wallpaper added ✓ — replace';
        renderWallpaper(r);
        serialize();
      }).catch(function () { cta.textContent = 'Upload failed — tap to retry'; });
    });

    el.querySelector('.rc-height').addEventListener('input', function () { recompute(r); });
    el.querySelector('.rc-width').addEventListener('input', function () { renderWallpaper(r); serialize(); });
    el.querySelector('.rc-room-label').addEventListener('input', serialize);
    ['.rc-wp-width', '.rc-wp-repeat', '.rc-wp-match'].forEach(function (sel) {
      el.querySelector(sel).addEventListener('input', function () { renderWallpaper(r); serialize(); });
    });

    wireBox(r);

    // Sensor measurement — WebXR AR or a gyro clinometer (see room-measure.js).
    // Pure progressive enhancement: the button stays hidden unless the device
    // actually has a usable sensor, and any failure just leaves the manual
    // dimension inputs exactly as they were.
    var measureBtn = el.querySelector('.rc-measure-btn');
    var measureNote = el.querySelector('.rc-measure-note');
    if (measureBtn && window.RoomMeasure) {
      window.RoomMeasure.ready.then(function () {
        var lbl = window.RoomMeasure.label();
        if (!lbl) return;                 // no sensors on this device — stay hidden
        measureBtn.textContent = lbl;
        measureBtn.hidden = false;
      });
      measureBtn.addEventListener('click', function () {
        measureNote.className = 'rc-measure-note';
        measureNote.textContent = 'Starting measurement…';
        measureBtn.disabled = true;
        window.RoomMeasure.measure().then(function (m) {
          var hEl = el.querySelector('.rc-height'), wEl = el.querySelector('.rc-width');
          // Set height first: its 'input' fires recompute(), which box-derives
          // a width — then a real measured width (WebXR) overwrites that.
          if (m.height_ft) {
            hEl.value = m.height_ft;
            hEl.dispatchEvent(new Event('input', { bubbles: true }));
          }
          if (m.width_ft) {
            wEl.value = m.width_ft;
            wEl.dispatchEvent(new Event('input', { bubbles: true }));
          }
          measureNote.className = 'rc-measure-note is-ok';
          measureNote.textContent = (m.method === 'webxr')
            ? '✓ Measured in AR: ' + (m.width_ft || '?') + ' ft wide × '
              + (m.height_ft || '?') + ' ft tall — adjust above if needed.'
            : '✓ Measured height: ' + m.height_ft + ' ft — confirm the width above.';
        }).catch(function (err) {
          var msg = (err && err.message) || 'Measurement failed.';
          if (msg === 'cancelled') {
            measureNote.textContent = '';
          } else {
            measureNote.className = 'rc-measure-note is-error';
            measureNote.textContent = msg;
          }
        }).then(function () { measureBtn.disabled = false; });
      });
    }
  }

  function addRoom() {
    var el = tpl.content.cloneNode(true).querySelector('.rc-room');
    var r = { el: el, box: { l: 0.15, t: 0.10, w: 0.70, h: 0.80 } };
    rooms.push(r);
    roomsEl.appendChild(el);
    wireRoom(r);
  }

  addBtn.addEventListener('click', addRoom);
  addRoom(); // open with one room ready
})();