@extends('layout') @section('title') <title>Trace hotspots & checkpoints — {{ $floor->name }}</title> @endsection @section('content') <section class="container py-4"> <div class="d-flex align-items-center justify-content-between mb-3"> <h1 class="h4 mb-0">Trace — {{ $floor->name }}</h1> <div class="d-flex gap-2"> <a class="btn btn-outline-secondary" href="{{ route('map.show', $floor->slug) }}">Back to Map</a> <a class="btn btn-outline-primary" href="{{ route('map.coords', $floor->slug) }}">Reset</a> </div> </div> <div class="row g-3"> <div class="col-lg-8"> <div class="ratio" style="--bs-aspect-ratio: calc({{ $floor->height }} / {{ $floor->width }} * 100%);"> <svg id="svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {{ $floor->width }} {{ $floor->height }}" class="w-100 h-100 border"> <image href="{{ asset($floor->image_path) }}" x="0" y="0" width="{{ $floor->width }}" height="{{ $floor->height }}" preserveAspectRatio="xMidYMid meet" /> {{-- Existing checkpoints on this floor --}} <g id="existing-cp"> @foreach($checkpoints as $cp) <g class="cp" data-id="{{ $cp->id }}" data-slug="{{ $cp->slug }}" transform="translate({{ $cp->x }}, {{ $cp->y }})"> <circle r="5" fill="#6c757d" stroke="#fff" stroke-width="2"></circle> <text x="8" y="4" font-size="12" fill="#212529">{{ $cp->slug }}</text> </g> @endforeach </g> {{-- Drawing layers --}} <g id="draw"></g> </svg> </div> <div class="small text-muted mt-2"> <strong>Area mode:</strong> click to add points → Close polygon. | <strong>Checkpoint mode:</strong> click once to set X,Y. | <strong>Link mode:</strong> choose From/To; Shift‑click = pick nearest as From, Ctrl/Cmd‑click = pick nearest as To. </div> </div> <div class="col-lg-4"> <div class="card"> <div class="card-body"> {{-- Mode --}} <div class="mb-3"> <label class="form-label">Mode</label> <div class="btn-group" role="group" aria-label="Mode"> <input type="radio" class="btn-check" name="mode" id="mode-area" autocomplete="off" checked> <label class="btn btn-outline-secondary" for="mode-area">Area</label> <input type="radio" class="btn-check" name="mode" id="mode-checkpoint" autocomplete="off"> <label class="btn btn-outline-secondary" for="mode-checkpoint">Checkpoint</label> <input type="radio" class="btn-check" name="mode" id="mode-link" autocomplete="off"> <label class="btn btn-outline-secondary" for="mode-link">Link</label> </div> </div> {{-- AREA (polygon) --}} <div id="panel-area"> <div class="mb-2"> <label class="form-label">Area name</label> <input id="area-name" class="form-control" list="name-suggestions" placeholder="e.g. Textile Gallery"> <datalist id="name-suggestions"> @foreach ($suggestions as $s) <option value="{{ $s }}"></option> @endforeach </datalist> </div> <div class="mb-2"> <label class="form-label">Slug (auto)</label> <input id="area-slug" class="form-control" readonly> </div> <div class="mb-2"> <label class="form-label">Points</label> <textarea id="area-points" class="form-control" rows="2" readonly></textarea> </div> <div class="d-flex gap-2 mb-3"> <button id="area-undo" class="btn btn-outline-secondary" type="button">Undo point</button> <button id="area-close" class="btn btn-outline-primary" type="button">Close polygon</button> <button id="area-reset" class="btn btn-outline-danger" type="button">Reset</button> </div> <div class="mb-2"> <label class="form-label">SQL (areas)</label> <textarea id="area-sql" class="form-control" rows="6" readonly></textarea> <div class="form-text">Copy into phpMyAdmin to INSERT/UPSERT a polygon in <code>areas</code>.</div> </div> </div> {{-- CHECKPOINT (single point) --}} <div id="panel-checkpoint" class="d-none"> <div class="mb-2"> <label class="form-label">Checkpoint name</label> <input id="cp-name" class="form-control" placeholder="e.g. Entrance (F1) or Junction near Information"> </div> <div class="mb-2"> <label class="form-label">Slug (unique)</label> <input id="cp-slug" class="form-control" placeholder="e.g. entrance-f1"> <div class="form-text">Must be globally unique. Use the same slug as your destination gallery (e.g. <code>textile-gallery</code>) if this checkpoint represents that gallery entrance.</div> </div> <div class="mb-2"> <label class="form-label">Type</label> <select id="cp-type" class="form-select"> <option value="entrance">entrance</option> <option value="junction">junction</option> <option value="stair">stair</option> <option value="lift">lift</option> <option value="area">area</option> <option value="other">other</option> </select> </div> <div class="row g-2 mb-2"> <div class="col"> <label class="form-label">X</label> <input id="cp-x" class="form-control" readonly> </div> <div class="col"> <label class="form-label">Y</label> <input id="cp-y" class="form-control" readonly> </div> </div> <div class="small text-muted mb-2">Click the map once to set X,Y.</div> <div class="mb-2"> <label class="form-label">SQL (checkpoints)</label> <textarea id="cp-sql" class="form-control" rows="6" readonly></textarea> </div> </div> {{-- LINK (between checkpoints) --}} <div id="panel-link" class="d-none"> <div class="mb-2"> <label class="form-label">From checkpoint</label> <select id="link-from" class="form-select"> <option value="">— select —</option> @foreach ($checkpoints as $cp) <option value="{{ $cp->slug }}">{{ $cp->slug }} ({{ $cp->name }})</option> @endforeach </select> <div class="form-text">Tip: Shift‑click on the map to set “From” to the nearest checkpoint.</div> </div> <div class="mb-2"> <label class="form-label">To checkpoint</label> <select id="link-to" class="form-select"> <option value="">— select —</option> @foreach ($checkpoints as $cp) <option value="{{ $cp->slug }}">{{ $cp->slug }} ({{ $cp->name }})</option> @endforeach </select> <div class="form-text">Tip: Ctrl/Cmd‑click on the map to set “To”.</div> </div> <div class="row g-2 mb-2"> <div class="col"> <label class="form-label">Weight (optional)</label> <input id="link-weight" class="form-control" placeholder="leave empty to use distance"> </div> <div class="col"> <label class="form-label">Direction</label> <select id="link-bidir" class="form-select"> <option value="1">bidirectional</option> <option value="0">one-way (from → to)</option> </select> </div> </div> <div class="mb-2"> <div id="link-preview" class="small text-muted"></div> </div> <div class="mb-2"> <label class="form-label">SQL (links)</label> <textarea id="link-sql" class="form-control" rows="6" readonly></textarea> </div> </div> <div class="alert alert-info mt-3 mb-0"> Floor: <strong>{{ $floor->name }}</strong> (id: <code>{{ $floor->id }}</code>)<br> Image size: <code>{{ $floor->width }} × {{ $floor->height }}</code> </div> </div> </div> </div> </div> </section> <style> #svg { user-select: none; } #draw .pt { fill: #dc3545; } #draw .polyline, #draw .polygon { fill: rgba(13,110,253, .12); stroke: rgba(13,110,253, .9); stroke-width: 2; } /* Checkpoint previews */ #draw .cp-preview { fill: #0d6efd; stroke:#fff; stroke-width:2; } /* Link preview */ #draw .link-line { stroke: #198754; stroke-width: 3; fill: none; } </style> <script> (function(){ const svg = document.getElementById('svg'); const draw = document.getElementById('draw'); const cps = @json($checkpoints); const floorId = {{ $floor->id }}; // Mode toggles const panelArea = document.getElementById('panel-area'); const panelCp = document.getElementById('panel-checkpoint'); const panelLink = document.getElementById('panel-link'); const modeArea = document.getElementById('mode-area'); const modeCp = document.getElementById('mode-checkpoint'); const modeLink = document.getElementById('mode-link'); function setMode(mode){ panelArea.classList.toggle('d-none', mode !== 'area'); panelCp.classList.toggle('d-none', mode !== 'checkpoint'); panelLink.classList.toggle('d-none', mode !== 'link'); } modeArea.addEventListener('change', ()=> setMode('area')); modeCp.addEventListener('change', ()=> setMode('checkpoint')); modeLink.addEventListener('change', ()=> setMode('link')); setMode('area'); // -------- helpers function slugify(s){ return String(s || '') .toLowerCase() .replace(/&/g,' and ') .replace(/[^a-z0-9\s-]/g,'') .trim() .replace(/\s+/g,'-') .replace(/-+/g,'-'); } function svgPoint(evt){ const pt = svg.createSVGPoint(); pt.x = evt.clientX; pt.y = evt.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); } function nearestCp(p){ let best = null, bestD = Infinity; cps.forEach(cp => { const dx = cp.x - p.x, dy = cp.y - p.y; const d = dx*dx + dy*dy; if(d < bestD) { bestD = d; best = cp; } }); return best; } // -------- AREA mode const areaName = document.getElementById('area-name'); const areaSlug = document.getElementById('area-slug'); const areaPts = document.getElementById('area-points'); const areaSQL = document.getElementById('area-sql'); const areaUndo = document.getElementById('area-undo'); const areaClose= document.getElementById('area-close'); const areaReset= document.getElementById('area-reset'); let polyline = null, polygon = null, pts = []; function areaUpdateOutputs(){ const pointsStr = pts.map(p => `${Math.round(p.x)},${Math.round(p.y)}`).join(' '); areaPts.value = pointsStr; const name = areaName.value || ''; const slug = slugify(name); areaSlug.value = slug; const href = `/block/a/gallery/${slug}`; // change /a/ if needed per area const sql = `INSERT INTO areas (floor_id,name,slug,shape,points,href,is_active,created_at,updated_at) VALUES (${floorId}, '${name.replace(/'/g,"''")}', '${slug}', 'polygon', '${pointsStr}', '${href}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), points=VALUES(points), href=VALUES(href), updated_at=NOW();`; areaSQL.value = sql; } function areaDrawPreview(){ const pointsAttr = pts.map(p => `${Math.round(p.x)},${Math.round(p.y)}`).join(' '); if(!polyline){ polyline = document.createElementNS('http://www.w3.org/2000/svg','polyline'); polyline.setAttribute('class','polyline'); draw.appendChild(polyline); } polyline.setAttribute('points', pointsAttr); areaUpdateOutputs(); } function areaAddPoint(evt){ const p = svgPoint(evt); pts.push({x:p.x, y:p.y}); const dot = document.createElementNS('http://www.w3.org/2000/svg','circle'); dot.setAttribute('class','pt'); dot.setAttribute('cx', p.x); dot.setAttribute('cy', p.y); dot.setAttribute('r', 4); draw.appendChild(dot); areaDrawPreview(); } function areaClosePolygon(){ if(pts.length < 3) { alert('Need at least 3 points'); return; } const pointsAttr = pts.map(p => `${Math.round(p.x)},${Math.round(p.y)}`).join(' '); if(polygon) polygon.remove(); polygon = document.createElementNS('http://www.w3.org/2000/svg','polygon'); polygon.setAttribute('class','polygon'); polygon.setAttribute('points', pointsAttr); draw.appendChild(polygon); if(polyline){ polyline.remove(); polyline = null; } areaUpdateOutputs(); } function areaResetAll(){ pts = []; draw.innerHTML = ''; polyline = polygon = null; areaUpdateOutputs(); } areaName.addEventListener('input', areaUpdateOutputs); areaUndo.addEventListener('click', ()=>{ if(pts.length === 0) return; pts.pop(); const dots = draw.querySelectorAll('.pt'); if(dots.length) dots[dots.length-1].remove(); if(polygon){ polygon.remove(); polygon = null; } areaDrawPreview(); }); areaClose.addEventListener('click', areaClosePolygon); areaReset.addEventListener('click', areaResetAll); // -------- CHECKPOINT mode const cpName = document.getElementById('cp-name'); const cpSlug = document.getElementById('cp-slug'); const cpType = document.getElementById('cp-type'); const cpX = document.getElementById('cp-x'); const cpY = document.getElementById('cp-y'); const cpSQL = document.getElementById('cp-sql'); let cpPreview = null; function cpUpdateSQL(){ const name = (cpName.value || '').replace(/'/g,"''"); const slug = cpSlug.value || slugify(cpName.value || ''); const type = cpType.value || 'other'; const x = cpX.value || 0; const y = cpY.value || 0; cpSlug.value = slug; const sql = `INSERT INTO checkpoints (floor_id,name,slug,x,y,type,created_at,updated_at) VALUES (${floorId}, '${name}', '${slug}', ${x}, ${y}, '${type}', NOW(), NOW()) ON DUPLICATE KEY UPDATE name=VALUES(name), x=VALUES(x), y=VALUES(y), floor_id=VALUES(floor_id), type=VALUES(type), updated_at=NOW();`; cpSQL.value = sql; } function cpPlace(evt){ const p = svgPoint(evt); cpX.value = Math.round(p.x); cpY.value = Math.round(p.y); if(cpPreview) cpPreview.remove(); cpPreview = document.createElementNS('http://www.w3.org/2000/svg','circle'); cpPreview.setAttribute('class','cp-preview'); cpPreview.setAttribute('cx', cpX.value); cpPreview.setAttribute('cy', cpY.value); cpPreview.setAttribute('r', 7); draw.appendChild(cpPreview); if(!cpSlug.value && cpName.value) cpSlug.value = slugify(cpName.value); cpUpdateSQL(); } cpName.addEventListener('input', cpUpdateSQL); cpSlug.addEventListener('input', cpUpdateSQL); cpType.addEventListener('change', cpUpdateSQL); // -------- LINK mode const linkFrom = document.getElementById('link-from'); const linkTo = document.getElementById('link-to'); const linkW = document.getElementById('link-weight'); const linkBi = document.getElementById('link-bidir'); const linkSQL = document.getElementById('link-sql'); const linkPrev = document.getElementById('link-preview'); let linkLine = null; function findCpBySlug(slug){ return cps.find(c => c.slug === slug); } function drawLinkPreview(){ if(linkLine) { linkLine.remove(); linkLine = null; } const a = findCpBySlug(linkFrom.value); const b = findCpBySlug(linkTo.value); if(!(a && b)) { linkPrev.textContent = ''; return; } linkLine = document.createElementNS('http://www.w3.org/2000/svg','line'); linkLine.setAttribute('class','link-line'); linkLine.setAttribute('x1', a.x); linkLine.setAttribute('y1', a.y); linkLine.setAttribute('x2', b.x); linkLine.setAttribute('y2', b.y); draw.appendChild(linkLine); const dx = a.x - b.x, dy = a.y - b.y; const dist = Math.round(Math.sqrt(dx*dx + dy*dy)); linkPrev.textContent = `Distance ≈ ${dist}px`; } function updateLinkSQL(){ const a = findCpBySlug(linkFrom.value); const b = findCpBySlug(linkTo.value); const w = linkW.value ? Number(linkW.value) : 'NULL'; const bi = linkBi.value === '1' ? 1 : 0; if(!(a && b)){ linkSQL.value = ''; drawLinkPreview(); return; } const sql = `INSERT INTO checkpoint_links (from_id,to_id,weight,bidirectional,created_at,updated_at) VALUES ((SELECT id FROM checkpoints WHERE slug='${a.slug}'), (SELECT id FROM checkpoints WHERE slug='${b.slug}'), ${w}, ${bi}, NOW(), NOW());`; linkSQL.value = sql; drawLinkPreview(); } [linkFrom, linkTo, linkW, linkBi].forEach(el => el.addEventListener('input', updateLinkSQL)); // -------- SVG Click dispatcher by mode svg.addEventListener('click', (evt)=>{ if (modeArea.checked) return areaAddPoint(evt); if (modeCp.checked) return cpPlace(evt); if (modeLink.checked) { if (evt.shiftKey) { const cp = nearestCp(svgPoint(evt)); if(cp){ linkFrom.value = cp.slug; updateLinkSQL(); } } else if (evt.ctrlKey || evt.metaKey) { const cp = nearestCp(svgPoint(evt)); if(cp){ linkTo.value = cp.slug; updateLinkSQL(); } } } }); // init outputs areaUpdateOutputs(); cpUpdateSQL(); updateLinkSQL(); })(); </script> @endsection