← 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
})();