[Back] @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