@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