[Back] @extends('layout')
@section('title')
<title>{{ $floor->name }} — Map</title>
@endsection
@section('content')
<section class="container py-4">
{{-- @if(app()->environment('local'))
<a class="btn btn-warning btn-sm mb-3" href="{{ route('map.coords', $floor->slug) }}">
Trace hotspots on {{ $floor->name }}
</a>
@endif --}}
{{-- Top bar: floor tabs + Scan QR --}}
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<ul class="nav nav-pills">
@foreach($floors as $f)
<li class="nav-item">
<a class="nav-link {{ $f->id === $floor->id ? 'active' : '' }}" href="{{ route('map.show', $f->slug) }}">
{{ $f->name }}
</a>
</li>
@endforeach
</ul>
<a href="{{ route('nav.scan') }}" class="btn btn-outline-primary btn-sm">Scan QR</a>
</div>
@if(session('status'))
<div class="alert alert-info mb-3">{{ session('status') }}</div>
@endif
<div class="card shadow-sm">
<div class="card-body">
<div class="w-100" style="max-width: 1100px; margin: 0 auto;">
<div class="ratio" style="--bs-aspect-ratio: calc({{ $floor->height }} / {{ $floor->width }} * 100%);">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 {{ $floor->width }} {{ $floor->height }}"
class="w-100 h-100" role="img" aria-label="{{ $floor->name }}">
{{-- Arrowhead for animated first leg --}}
<defs>
<marker id="arrow-next"
markerWidth="4" markerHeight="4"
refX="4" refY="2"
orient="auto"
markerUnits="strokeWidth">
<path d="M0,0 L4,2 L0,4 Z" fill="#198754" />
</marker>
</defs>
{{-- Background --}}
<image href="{{ asset($floor->image_path) }}"
x="0" y="0" width="{{ $floor->width }}" height="{{ $floor->height }}"
preserveAspectRatio="xMidYMid meet" />
{{-- Hotspots --}}
<g class="hotspots">
@foreach($areas as $a)
@php $href = $a->href ?: '#'; @endphp
<a href="{{ $href }}" aria-label="{{ $a->name }}">
@switch($a->shape)
@case('rect') <rect class="hotspot" x="{{ $a->x }}" y="{{ $a->y }}" width="{{ $a->width }}" height="{{ $a->height }}" /> @break
@case('path') <path class="hotspot" d="{{ $a->d }}" /> @break
@default <polygon class="hotspot" points="{{ $a->points }}" />
@endswitch
<title>{{ $a->name }}</title>
</a>
@endforeach
</g>
{{-- All checkpoints on this floor (faint) --}}
<g class="floor-cp">
@foreach($floorCheckpoints as $cp)
<g transform="translate({{ $cp->x }}, {{ $cp->y }})">
<circle r="4" class="cp-faint" />
</g>
@endforeach
</g>
{{-- ROUTE baseline (dotted) --}}
<g class="route-layer">
@if(!empty($segmentsByFloor[$floor->id]))
@php
$pointsArr = [];
foreach ($segmentsByFloor[$floor->id] as $pair) {
$pair = trim($pair);
if ($pair==='') continue;
foreach (explode(' ', $pair) as $pt) if ($pt!=='') $pointsArr[] = $pt;
}
$clean = []; $last=null;
foreach ($pointsArr as $pt){ if ($pt !== $last) $clean[] = $pt; $last = $pt; }
$pointsStr = implode(' ', $clean);
@endphp
@if($pointsStr !== '')
<polyline class="route dotted" points="{{ $pointsStr }}" />
@endif
@endif
{{-- Animated first leg (current -> next) --}}
@if(!empty($blinkSegmentPointsByFloor[$floor->id]))
<polyline class="route march-line"
points="{{ $blinkSegmentPointsByFloor[$floor->id] }}"
marker-end="url(#arrow-next)" />
@endif
{{-- Numbered route checkpoints (global numbering) --}}
@if(!empty($routeCpsByFloor[$floor->id]))
@foreach($routeCpsByFloor[$floor->id] as $rcp)
<g transform="translate({{ $rcp->x }}, {{ $rcp->y }})" class="route-cp">
<circle r="8" class="route-cp-dot" />
<text y="4" text-anchor="middle" class="route-cp-num">
{{ $routeStepIndex[$rcp->id] ?? '' }}
</text>
</g>
@endforeach
@endif
{{-- CURRENT (blinking) --}}
@if($currentDot && $currentCp && $currentCp->floor_id === $floor->id)
<g>
<circle class="current blink" cx="{{ $currentDot['x'] }}" cy="{{ $currentDot['y'] }}" r="9" />
<text x="{{ $currentDot['x'] }}" y="{{ $currentDot['y'] + 18 }}"
text-anchor="middle" font-size="12" fill="#212529">You are here</text>
</g>
@endif
{{-- DESTINATION --}}
@if($destDot && $destCp && $destCp->floor_id === $floor->id)
<g>
<circle class="dest" cx="{{ $destDot['x'] }}" cy="{{ $destDot['y'] }}" r="8" />
<text x="{{ $destDot['x'] + 14 }}" y="{{ $destDot['y'] - 12 }}" font-size="12" fill="#212529">{{ $destCp->name }}</text>
</g>
@endif
</g>
</svg>
</div>
</div>
{{-- Status & actions --}}
<div class="d-flex flex-wrap gap-2 align-items-center mt-3">
@if($currentCp)<span class="badge text-bg-secondary">Current: {{ $currentCp->name }}</span>@endif
@if($destCp)<span class="badge text-bg-primary">Destination: {{ $destCp->name }}</span>@endif
@if(!empty($nextInstruction)) <span class="ms-2 small text-muted">Next: {{ $nextInstruction }}</span> @endif
<div class="ms-auto d-flex flex-wrap gap-2 align-items-center">
{{-- Voice controls --}}
<div class="form-check form-switch me-2">
<input class="form-check-input" type="checkbox" id="voiceToggle">
<label class="form-check-label" for="voiceToggle">Enable voice</label>
</div>
<button id="voiceReplay" class="btn btn-sm btn-outline-primary" type="button">Replay</button>
{{-- Manual "I'm here" setter --}}
<form method="POST" action="{{ route('nav.set') }}" class="d-flex gap-2 ms-3">
@csrf
<select name="slug" class="form-select form-select-sm">
<option value="">I’m here…</option>
@foreach($floorCheckpoints as $cp)
<option value="{{ $cp->slug }}">{{ $cp->name }}</option>
@endforeach
</select>
<button class="btn btn-sm btn-outline-success" type="submit">Set</button>
</form>
@php
$currentFloorSlug = $currentCp ? ($floors->firstWhere('id', $currentCp->floor_id)->slug ?? null) : null;
$destFloorSlug = $destCp ? ($floors->firstWhere('id', $destCp->floor_id)->slug ?? null) : null;
@endphp
@if($currentFloorSlug && $currentFloorSlug !== $floor->slug)
<a class="btn btn-sm btn-outline-secondary" href="{{ route('map.show', $currentFloorSlug) }}">Go to Current Floor</a>
@endif
@if($destFloorSlug && $destFloorSlug !== $floor->slug)
<a class="btn btn-sm btn-outline-primary" href="{{ route('map.show', $destFloorSlug) }}">Go to Destination Floor</a>
@endif
<a href="{{ route('nav.clear') }}" class="btn btn-sm btn-outline-danger">Clear navigation</a>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<a href="{{ url('/home') }}" class="btn btn-outline-secondary">Back to Menu</a>
</div>
</div>
</div>
</section>
{{-- VOICE (auto-speak on ?s=1 or after QR) --}}
<div id="voice-nudge" class="voice-nudge d-none">🔊 Tap to enable voice guidance</div>
{{-- <script>
(function() {
const steps = @json($ttsSteps ?? []);
const lang = @json($ttsLang ?? 'en-US');
const toggle = document.getElementById('voiceToggle');
const replay = document.getElementById('voiceReplay');
const nudge = document.getElementById('voice-nudge');
const enabledKey = 'map_voice_enabled';
const unlockedKey = 'map_voice_unlocked';
// Want to auto-speak? (from nav/qr/scan we append s=1)
const params = new URLSearchParams(window.location.search);
const wantSpeak = params.has('s') || params.get('speak') === '1';
// Restore state
const wasEnabled = localStorage.getItem(enabledKey) === '1';
let isUnlocked = sessionStorage.getItem(unlockedKey) === '1';
// If asked to speak, default enable voice for the user
const enableNow = wasEnabled || wantSpeak;
localStorage.setItem(enabledKey, enableNow ? '1' : '0');
toggle.checked = enableNow;
function speak(text) {
if (!text) return;
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = lang; u.rate = 1; u.pitch = 1; u.volume = 1;
function pickVoiceAndSpeak() {
const voices = window.speechSynthesis.getVoices() || [];
let v = voices.find(v => v.lang === lang)
|| voices.find(v => v.lang && v.lang.startsWith(lang.split('-')[0]))
|| voices.find(v => /en-/i.test(v.lang))
|| voices[0];
if (v) u.voice = v;
window.speechSynthesis.speak(u);
}
if (window.speechSynthesis.getVoices().length === 0) {
window.speechSynthesis.onvoiceschanged = pickVoiceAndSpeak;
} else {
pickVoiceAndSpeak();
}
}
// Unlock on first real gesture
['click','touchstart','keydown'].forEach(evt => {
document.addEventListener(evt, () => {
sessionStorage.setItem(unlockedKey, '1');
isUnlocked = true;
if (!nudge.classList.contains('d-none') && toggle.checked && steps.length) {
nudge.classList.add('d-none');
speak(steps[0]);
}
}, { once:true, capture:true });
});
// Toggle & replay
toggle.addEventListener('change', () => {
localStorage.setItem(enabledKey, toggle.checked ? '1' : '0');
if (toggle.checked) {
sessionStorage.setItem(unlockedKey, '1');
isUnlocked = true;
if (steps.length) speak(steps[0]);
} else {
window.speechSynthesis.cancel();
}
});
replay.addEventListener('click', () => {
if (toggle.checked && steps.length) speak(steps[0]);
});
// Try to auto-speak on load if enabled + unlocked
function tryAutoSpeakOnce() {
if (!toggle.checked || !steps.length) return;
if (isUnlocked) {
setTimeout(() => speak(steps[0]), 120);
} else if (wantSpeak) {
// Show a one-tap nudge if the browser blocks autoplay (external camera QR case)
nudge.classList.remove('d-none');
}
}
window.addEventListener('pageshow', tryAutoSpeakOnce);
if (document.visibilityState === 'visible') tryAutoSpeakOnce();
})();
</script> --}}
<script>
(function () {
const steps = @json($ttsSteps ?? []);
const lang = @json($ttsLang ?? 'en-US');
const toggle = document.getElementById('voiceToggle');
const replay = document.getElementById('voiceReplay');
const nudge = document.getElementById('voice-nudge');
const LS_ENABLED = 'map_voice_enabled';
const SS_UNLOCK = 'map_voice_unlocked';
const LS_PREOK = 'map_voice_preapproved';
const params = new URLSearchParams(location.search);
const wantSpeak = params.has('s') || params.get('speak') === '1';
const wasEnabled = localStorage.getItem(LS_ENABLED) === '1';
const preapproved = localStorage.getItem(LS_PREOK) === '1';
let isUnlocked = sessionStorage.getItem(SS_UNLOCK) === '1';
// Default enable when asked to speak
const enableNow = wasEnabled || wantSpeak;
localStorage.setItem(LS_ENABLED, enableNow ? '1' : '0');
toggle.checked = enableNow;
// ======== tuning: shorter delay & faster retries ========
const RETRY_INTERVAL_MS = 150; // was 400ms
const MAX_TRIES = 20; // ~3s total
const RAPID_ATTEMPTS = 3; // 3 immediate RAF attempts
// ========================================================
let speakTimer = null;
let speakTries = 0;
let speakingNow = false;
// Pre-warm voices (helps some browsers load voices synchronously)
try { void window.speechSynthesis.getVoices(); } catch {}
function pickVoice(u) {
const voices = window.speechSynthesis.getVoices() || [];
return (
voices.find(v => v.lang === lang) ||
voices.find(v => v.lang && v.lang.startsWith(lang.split('-')[0])) ||
voices.find(v => /en-/i.test(v.lang)) ||
voices[0] || null
);
}
function actuallySpeak(text) {
if (!text || !('speechSynthesis' in window)) return false;
try { window.speechSynthesis.resume(); } catch {}
// Only cancel if we’re not already speaking; avoids cutting off first start
if (!speakingNow) window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = lang; u.rate = 1; u.pitch = 1; u.volume = 1;
u.onstart = () => {
speakingNow = true;
localStorage.setItem(LS_PREOK, '1');
nudge.classList.add('d-none');
};
u.onend = () => { speakingNow = false; };
u.onerror = () => { speakingNow = false; };
const v = pickVoice(u);
if (v) u.voice = v;
try {
window.speechSynthesis.speak(u);
return true;
} catch {
return false;
}
}
function stopRetry() {
if (speakTimer) { clearInterval(speakTimer); speakTimer = null; }
}
// 3 super-fast attempts via RAF to minimize perceived delay
function rapidAttemptsThenRetry() {
let left = RAPID_ATTEMPTS;
function tick() {
if (!toggle.checked || !steps.length) return;
if (actuallySpeak(steps[0])) return; // success — stop
left--;
if (left > 0) requestAnimationFrame(tick);
else startRetryLoop();
}
requestAnimationFrame(tick);
}
function startRetryLoop() {
speakTries = 0;
speakTimer = setInterval(() => {
if (speakingNow) { stopRetry(); return; }
speakTries++;
try { window.speechSynthesis.resume(); } catch {}
const ok = actuallySpeak(steps[0]);
if (ok || speakTries >= MAX_TRIES) {
stopRetry();
if (!ok) nudge.classList.remove('d-none'); // needs one tap
}
}, RETRY_INTERVAL_MS);
}
function tryAutoSpeakNow() {
if (!toggle.checked || !steps.length) return;
// Immediate attempt first (no delay)
if (actuallySpeak(steps[0])) return;
// Rapid attempts (~0–50ms), then tight retry loop
rapidAttemptsThenRetry();
}
// First user gesture unlocks this tab
['click','touchstart','keydown'].forEach(evt => {
document.addEventListener(evt, () => {
sessionStorage.setItem(SS_UNLOCK, '1');
isUnlocked = true;
if (toggle.checked && steps.length) {
nudge.classList.add('d-none');
tryAutoSpeakNow();
}
}, { once:true, capture:true });
});
// Toggle & replay
toggle.addEventListener('change', () => {
localStorage.setItem(LS_ENABLED, toggle.checked ? '1' : '0');
if (toggle.checked) {
sessionStorage.setItem(SS_UNLOCK, '1');
isUnlocked = true;
tryAutoSpeakNow();
} else {
window.speechSynthesis.cancel();
stopRetry();
}
});
replay.addEventListener('click', () => {
tryAutoSpeakNow();
});
// Auto-speak on load if there’s any chance
function maybeAutoSpeak() {
if (!toggle.checked || !steps.length) return;
const canTry = wantSpeak && (isUnlocked || preapproved);
if (canTry) {
tryAutoSpeakNow();
} else if (wantSpeak) {
// Even without unlock, try immediately; if blocked, we’ll show nudge
tryAutoSpeakNow();
}
}
window.addEventListener('pageshow', maybeAutoSpeak);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') maybeAutoSpeak();
});
if (document.visibilityState === 'visible') maybeAutoSpeak();
})();
</script>
<style>
/* hotspots */
.hotspots .hotspot { fill: rgba(0,0,0,0); stroke: rgba(0,0,0,0.28); stroke-width: 2; cursor: pointer; transition: fill .15s, stroke .15s; }
.hotspots a:hover .hotspot, .hotspots a:focus .hotspot { fill: rgba(13,110,253,.18); stroke: rgba(13,110,253,.85); }
/* faint checkpoints */
.cp-faint { fill:#6c757d; opacity:.35; }
/* dotted route */
.route-layer .route.dotted { fill:none; stroke:#198754; stroke-width:6; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; stroke-dasharray:1 14; opacity:.5; }
.route-layer .march-line { fill:none; stroke:#198754; stroke-width:6; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; stroke-dasharray:1 14; animation:march .9s linear infinite; }
/* numbered route checkpoints */
.route-cp .route-cp-dot { fill:#fff; stroke:#198754; stroke-width:3; }
.route-cp .route-cp-num { font-size:11px; fill:#198754; font-weight:700; text-shadow:0 1px 0 #fff; }
/* markers */
.route-layer .current, .route-layer .dest { stroke:#fff; stroke-width:2; pointer-events:none; }
.route-layer .current { fill:#dc3545; }
.route-layer .dest { fill:#0d6efd; }
/* blink for current */
.blink { animation: blink 1s steps(2, start) infinite; }
/* voice nudge */
.voice-nudge { position: fixed; inset:auto 16px 16px 16px; background:#0d6efd; color:#fff; padding:10px 14px; border-radius:12px; box-shadow:0 6px 20px rgba(0,0,0,.2); text-align:center; font-weight:600; z-index: 9999; }
.d-none { display:none !important; }
/* animations */
@keyframes march { to { stroke-dashoffset: -15; } }
@keyframes blink { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.blink, .route-layer .march-line { animation: none; }
}
</style>
@endsection