1914 lines
86 KiB
JavaScript
1914 lines
86 KiB
JavaScript
// Fox HUD — Alpine.js Komponente. ES-Modul, gebundlet via app.js.
|
||
import { WIEN_UBAHN, WIEN_STOPS } from './fox-data.js';
|
||
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('foxHud', (cities, homeCity) => ({
|
||
cities,
|
||
homeCity,
|
||
time: '',
|
||
dateLabel: '',
|
||
weatherLabel: '— · —',
|
||
mapVisible: false,
|
||
edgeGlow: false,
|
||
micActive: false,
|
||
map: null,
|
||
mapLoaded: false,
|
||
currentCenter: [16.3738, 48.2082],
|
||
currentLatLng: { lat: 48.2082, lng: 16.3738 },
|
||
stats: { cpu: 0, ram_gb: 0 },
|
||
ollamaOk: null,
|
||
openaiOk: false,
|
||
reverbOk: false,
|
||
|
||
// Phrasen-Pools: Fox soll nicht jedesmal den gleichen Satz sagen.
|
||
phrases: {
|
||
retrying: [
|
||
'Sekunde, der Server hat kurz geblinzelt - ich versuchs nochmal.',
|
||
'Hmm, einer von beiden hat gerade geschlafen. Moment.',
|
||
'Komische Pause - ich frag nochmal nach.',
|
||
'Kurz, das Netz war zickig. Zweiter Anlauf.',
|
||
'Moment, das hat geruckelt. Probier ich nochmal.',
|
||
],
|
||
routeFailed: [
|
||
'Ich komm grad nicht an die Route ran. Probier es in einer Minute nochmal.',
|
||
'Der Routendienst ist gerade weg vom Fenster. Gib mir gleich nochmal die Chance.',
|
||
'Mehrfach probiert - keine Verbindung. Eine Minute später nochmal?',
|
||
'Aktuell kein Durchkommen zum Kartendienst. Versuchs gleich nochmal.',
|
||
],
|
||
recommendIntro: [
|
||
'Probier doch',
|
||
'Ich würd dir',
|
||
'Schau mal',
|
||
'Magst du',
|
||
'Mein Tipp:',
|
||
],
|
||
confirmYes: [
|
||
'Geht klar.',
|
||
'Mach ich.',
|
||
'Passt.',
|
||
'Auf gehts.',
|
||
'Schon dran.',
|
||
],
|
||
confirmNo: [
|
||
'Okay, alles klar.',
|
||
'Verstehe, lass uns das.',
|
||
'Passt, dann nicht.',
|
||
'Alles gut.',
|
||
],
|
||
},
|
||
pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; },
|
||
|
||
routeInfoVisible: false,
|
||
routeInfoLabel: '',
|
||
routeStepsVisible: false,
|
||
routeSteps: [],
|
||
routeStepsTarget: '',
|
||
placeInfo: null,
|
||
placeInfoVisible: false,
|
||
placeInfoExpanded: false,
|
||
placeInfoLoading: false,
|
||
placeDesk: { visible: false, data: null, liftedCard: null },
|
||
statusVisible: false,
|
||
statusLines: [],
|
||
statusScanning: false,
|
||
cityName: null,
|
||
cityChoices: [],
|
||
cityChoicesVisible: false,
|
||
cityChoiceQuery: '',
|
||
model: 'qwen2.5:32b',
|
||
transitOn: false,
|
||
stopsOn: false,
|
||
mediaRecorder: null,
|
||
micChunks: [],
|
||
poiList: [],
|
||
poiLabel: '',
|
||
poiLoading: false,
|
||
isSpeaking: false,
|
||
|
||
brainVisible: false,
|
||
brainStats: { nodes: 0, edges: 0, pending: 0 },
|
||
_brainInit: false,
|
||
|
||
codePanel: {
|
||
visible: false,
|
||
title: 'fox@terminal',
|
||
description: '',
|
||
code: '',
|
||
language: 'php',
|
||
files: [],
|
||
activeFile: 0,
|
||
typing: false,
|
||
typingText: 'Fox schreibt...',
|
||
},
|
||
codeCopied: false,
|
||
|
||
init() {
|
||
this.tick();
|
||
setInterval(() => this.tick(), 1000);
|
||
// HUD-State ständig synchron mit Livewire halten
|
||
setInterval(() => this.updateHudState(), 1200);
|
||
|
||
// GPS-Position holen (HTTPS-Pflicht - läuft jetzt via fox.pxo.at).
|
||
// Wird bei Routenanfragen als Origin genutzt statt Map-Mitte.
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
this._userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
||
console.log('[Fox HUD] GPS:', this._userLocation);
|
||
},
|
||
(err) => console.log('[Fox HUD] GPS abgelehnt/fehler:', err.message),
|
||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
|
||
);
|
||
}
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
if (this.brainVisible) { this.closeBrain(); return; }
|
||
if (this.codePanel?.visible) { this.closeCodePanel(); return; }
|
||
}
|
||
});
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (this.$refs.cur) {
|
||
this.$refs.cur.style.left = e.clientX + 'px';
|
||
this.$refs.cur.style.top = e.clientY + 'px';
|
||
this.$refs.curDot.style.left = e.clientX + 'px';
|
||
this.$refs.curDot.style.top = e.clientY + 'px';
|
||
}
|
||
});
|
||
|
||
// Echo-Bridges: Listener referenzieren window-Globals,
|
||
// bei Re-Mount zeigt die Variable auf die aktuelle Instanz.
|
||
window._foxAction = (data) => this.onAction(data);
|
||
window._foxHudCloseBrain = () => this.closeBrain();
|
||
window._foxStats = (data) => {
|
||
this.stats = {
|
||
cpu: data?.cpu ?? 0,
|
||
ram_gb: Math.round((data?.memory?.used_mb ?? 0) / 1024),
|
||
};
|
||
this.ollamaOk = !!data?.ollama?.online;
|
||
this.openaiOk = !!data?.openai?.online;
|
||
};
|
||
window._foxWeather = (data) => {
|
||
if (data?.temperature !== null && data?.temperature !== undefined) {
|
||
this.weatherLabel = `${data.city.toUpperCase()} · ${data.temperature.toString().replace('.', ',')}°C`;
|
||
}
|
||
};
|
||
window._foxMessage = (data) => {
|
||
if (data?.role === 'assistant') {
|
||
this.flashEdge();
|
||
if (data?.content) this.notify(data.content);
|
||
}
|
||
if (data?.role === 'tool' && data?.metadata?.url) {
|
||
window.open(data.metadata.url, '_blank', 'noopener');
|
||
this.notify(`↗ ${data.content || 'Datei geöffnet'}`);
|
||
}
|
||
};
|
||
|
||
// Bridge: bootstrap.js feuert window-Event wenn die globale Audio
|
||
// läuft (LLM-Antwort). Wir schalten dann den Speaking-State auf true.
|
||
window.addEventListener('fox-speaking', (e) => { this.isSpeaking = !!e.detail; });
|
||
|
||
this.$watch('isSpeaking', (val) => {
|
||
const el = this.$el;
|
||
const targets = [
|
||
el.querySelector('.fox-arc-top'),
|
||
el.querySelector('.fox-arc-mid'),
|
||
el.querySelector('.fox-arc-bot'),
|
||
...Array.from(el.querySelectorAll('.fox-outer-ring circle')),
|
||
];
|
||
if (val) {
|
||
this._stopArcRandom();
|
||
this._smoothRate(targets, 10, 600);
|
||
} else {
|
||
this._smoothRate(targets, 1, 1000);
|
||
setTimeout(() => this._startArcRandom(), 1100);
|
||
}
|
||
});
|
||
|
||
this.$watch('placeDesk.visible', (val) => {
|
||
document.body.classList.toggle('desk-visible', val);
|
||
});
|
||
|
||
this.$nextTick(() => this._startArcRandom());
|
||
|
||
this.initMap();
|
||
this.subscribeEcho();
|
||
|
||
document.addEventListener('livewire:morphed', () => {
|
||
if (this.mapVisible) {
|
||
document.body.classList.add('map-visible');
|
||
if (this.map) this.$nextTick(() => { try { this.map.resize(); } catch (e) {} });
|
||
}
|
||
this.updateHudState();
|
||
});
|
||
},
|
||
|
||
subscribeEcho(retries = 20) {
|
||
if (window._foxHudEchoInit) {
|
||
this.reverbOk = true;
|
||
return;
|
||
}
|
||
if (!window.Echo) {
|
||
if (retries > 0) {
|
||
setTimeout(() => this.subscribeEcho(retries - 1), 200);
|
||
} else {
|
||
console.warn('[Fox HUD] window.Echo nicht verfügbar');
|
||
}
|
||
return;
|
||
}
|
||
window._foxHudEchoInit = true;
|
||
console.log('[Fox HUD] Echo gefunden, abonniere fox-channel');
|
||
|
||
const ch = window.Echo.channel('fox');
|
||
ch.listen('.fox.action', (p) => { console.log('[Fox HUD] fox.action:', p); window._foxAction?.(p); });
|
||
ch.listen('.message.created', (p) => window._foxMessage?.(p));
|
||
ch.listen('.stats.system', (p) => window._foxStats?.(p));
|
||
ch.listen('.stats.weather', (p) => window._foxWeather?.(p));
|
||
|
||
this.reverbOk = true;
|
||
},
|
||
|
||
initMap() {
|
||
if (typeof maplibregl === 'undefined') {
|
||
console.error('[Fox HUD] maplibregl nicht geladen');
|
||
return;
|
||
}
|
||
if (window._foxHudMap) {
|
||
console.log('[Fox HUD] Map existiert bereits - reuse');
|
||
this.map = window._foxHudMap;
|
||
this.mapLoaded = window._foxHudMapLoaded === true;
|
||
if (!this.mapLoaded) {
|
||
this.map.once('load', () => { this.mapLoaded = true; window._foxHudMapLoaded = true; });
|
||
}
|
||
return;
|
||
}
|
||
const target = document.getElementById('map');
|
||
if (!target) {
|
||
setTimeout(() => this.initMap(), 100);
|
||
return;
|
||
}
|
||
try {
|
||
this.map = new maplibregl.Map({
|
||
container: target,
|
||
style: 'https://tiles.openfreemap.org/styles/dark',
|
||
center: this.currentCenter,
|
||
zoom: 2, pitch: 0, bearing: 0,
|
||
antialias: true,
|
||
attributionControl: false,
|
||
fadeDuration: 150,
|
||
});
|
||
window._foxHudMap = this.map;
|
||
this.map.on('load', () => {
|
||
this.mapLoaded = true;
|
||
window._foxHudMapLoaded = true;
|
||
console.log('[Fox HUD] Map ready, size:', target.offsetWidth, 'x', target.offsetHeight);
|
||
});
|
||
this.map.on('error', (e) => console.warn('[Fox HUD] Map error:', e?.error?.message || e));
|
||
} catch (err) {
|
||
console.error('[Fox HUD] Map-Init-Fehler:', err);
|
||
}
|
||
},
|
||
|
||
tick() {
|
||
const now = new Date();
|
||
this.time = now.toLocaleTimeString('de-AT', { hour12: false });
|
||
const day = now.toLocaleDateString('de-AT', { weekday: 'short' }).toUpperCase().replace('.', '');
|
||
const date = now.toLocaleDateString('de-AT', { day: '2-digit', month: 'short', year: 'numeric' });
|
||
this.dateLabel = `${day} ${date.toUpperCase()}`;
|
||
},
|
||
|
||
stateLabel() {
|
||
if (this.poiLoading) return 'Sucht';
|
||
const s = this.$wire.status;
|
||
return ({ thinking: 'Denkt nach', speaking: 'Spricht', listening: 'Hört' }[s]) || 'Bereit';
|
||
},
|
||
|
||
async onAction(payload) {
|
||
console.log('[Fox HUD] onAction:', payload);
|
||
if (!payload) return;
|
||
// User hat etwas getippt → ausstehende Followup-Frage abbrechen
|
||
this.cancelFollowup();
|
||
|
||
// Dedup: IntentDetector feuert sofort, das LLM ~5s später oft mit gleicher
|
||
// Action+City. Ohne Guard würden wir doppelt sprechen.
|
||
const dedupKey = [
|
||
payload.action,
|
||
payload.city || '',
|
||
payload.category || '',
|
||
payload.cuisine || '',
|
||
payload.auto_recommend ? 'rec' : '',
|
||
payload.to || '',
|
||
].join(':');
|
||
const now = Date.now();
|
||
if (this._lastActionKey === dedupKey && now - (this._lastActionAt || 0) < 12000) {
|
||
console.log('[Fox HUD] dedup: skip', dedupKey);
|
||
return;
|
||
}
|
||
this._lastActionKey = dedupKey;
|
||
this._lastActionAt = now;
|
||
|
||
// URL in neuem Tab öffnen (z.B. generiertes Template)
|
||
if (payload.action === 'open_url' && payload.url) {
|
||
if (payload.text) { this.say(payload.text); this.notify(payload.text); }
|
||
setTimeout(() => window.open(payload.url, '_blank', 'noopener'), 800);
|
||
return;
|
||
}
|
||
|
||
// Sofort-Ankündigung ("Einen Moment, ich schau...") - nur sprechen
|
||
if (payload.action === 'announce') {
|
||
if (payload.text) {
|
||
this.notify(payload.text);
|
||
this.say(payload.text);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Chain: nach Hauptaktion noch eine Folgeaktion ausführen (z.B. "schließen UND erinner...")
|
||
if (payload.chain && Array.isArray(payload.chain)) {
|
||
const chain = payload.chain;
|
||
delete payload.chain;
|
||
setTimeout(() => chain.forEach((next, i) => setTimeout(() => this.onAction(next), i * 250)), 400);
|
||
}
|
||
|
||
// Dynamische Map: unbekannte Stadt via Google Geocoding lookup
|
||
if (payload.action === 'map_dynamic' && payload.query) {
|
||
this.lookupCity(payload.query);
|
||
return;
|
||
}
|
||
|
||
// Reminder / Timer - mit menschlich klingenden Erinnerungs-Sätzen
|
||
if (payload.action === 'reminder' && payload.delay_seconds) {
|
||
if (payload.text) this.say(payload.text);
|
||
this.notify(`⏰ ${payload.what} · ${this.formatDelay(payload.delay_seconds)}`);
|
||
setTimeout(() => {
|
||
this.flashEdge();
|
||
this.notify(`⏰ ${payload.what}`);
|
||
this.say(this.buildReminderSentence(payload.what));
|
||
}, payload.delay_seconds * 1000);
|
||
return;
|
||
}
|
||
|
||
// Brain-Visualisierung öffnen
|
||
if (payload.action === 'brain') {
|
||
if (payload.text) this.notify(payload.text);
|
||
await this.openBrain();
|
||
return;
|
||
}
|
||
|
||
// System-Status / Diagnose mit AI-Animation
|
||
if (payload.action === 'system_status') {
|
||
this.runSystemStatus();
|
||
return;
|
||
}
|
||
|
||
// "Karte zu / schließen" → Map verstecken, Fox in die Mitte
|
||
if (payload.action === 'close_map') {
|
||
this.closeMap();
|
||
this.removeRouteLayer();
|
||
if (payload.text) this.say(payload.text);
|
||
else this.say(this.pick(['Karte zu.', 'Erledigt.', 'Geschlossen.', 'Passt.']));
|
||
return;
|
||
}
|
||
if (payload.action === 'close_poi') {
|
||
this.clearPois();
|
||
if (payload.text) this.say(payload.text);
|
||
else this.say('POI-Liste weg.');
|
||
return;
|
||
}
|
||
if (payload.action === 'close_transit') {
|
||
this.disableTransit?.();
|
||
if (payload.text) this.say(payload.text);
|
||
else this.say('Liniennetz aus.');
|
||
return;
|
||
}
|
||
if (payload.action === 'close_status') {
|
||
this.statusVisible = false;
|
||
this.statusLines = [];
|
||
if (payload.text) this.say(payload.text);
|
||
else this.say('Diagnose zu.');
|
||
return;
|
||
}
|
||
|
||
// "Wie schaut der erste Bezirk aus" / "Erzähl mir was über X" → Detail-Panel mit Bild
|
||
if (payload.action === 'place_info' && payload.query) {
|
||
this.showPlaceInfo(payload.query);
|
||
return;
|
||
}
|
||
|
||
// "Zeig mir mehr" → ausführlichere Anzeige zur letzten Place-Info
|
||
if (payload.action === 'place_more') {
|
||
if (this.placeDesk.visible) { this.placeDesk.liftedCard = 'summary'; return; }
|
||
this.expandPlaceInfo();
|
||
return;
|
||
}
|
||
|
||
if (payload.action === 'desk_card') {
|
||
if (payload.card) {
|
||
if (this.placeDesk.visible) {
|
||
this.placeDesk.liftedCard = payload.card;
|
||
} else if (this._lastRecommend) {
|
||
this._pendingDeskCard = payload.card;
|
||
this.showPlaceInfo('__LAST_RECOMMEND__');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (payload.action === 'desk_down') {
|
||
this.placeDesk.liftedCard = null;
|
||
return;
|
||
}
|
||
|
||
// "Wie lange brauche ich" → letzte Route nochmal vorlesen, kein neuer Call.
|
||
if (payload.action === 'route_info') {
|
||
this.announceLastRoute();
|
||
return;
|
||
}
|
||
|
||
// Route: GPS bevorzugen → sonst Map-Mitte → sonst Heimat.
|
||
// Spezialfall "__LAST_RECOMMEND__": Ziel ist die zuletzt empfohlene Stelle.
|
||
if (payload.action === 'route' && payload.to) {
|
||
const origin = payload.from
|
||
|| (this._userLocation
|
||
? `${this._userLocation.lat},${this._userLocation.lng}`
|
||
: (this.mapVisible
|
||
? `${this.currentLatLng.lat},${this.currentLatLng.lng}`
|
||
: (this.homeCity ? `${this.homeCity.lat},${this.homeCity.lng}` : null)));
|
||
if (!origin) {
|
||
this.say('Ich brauche einen Startpunkt - sag mir woher.');
|
||
return;
|
||
}
|
||
|
||
let dest = payload.to;
|
||
if (dest === '__LAST_RECOMMEND__') {
|
||
if (this._lastRecommend) {
|
||
dest = `${this._lastRecommend.lat},${this._lastRecommend.lng}`;
|
||
// KEINE vorab-Ansage - sonst doppelter Ton mit dem Result-Spruch.
|
||
} else {
|
||
this.say('Ich weiß nicht wohin du meinst - sag mir konkret den Namen.');
|
||
return;
|
||
}
|
||
} else if (dest === '__LAST_DESTINATION__') {
|
||
// Mode-Wechsel ("zu fuß", "mit ubahn") - gleiches Ziel, neuer Mode.
|
||
if (this._lastRoute?.destination) {
|
||
dest = this._lastRoute.destination;
|
||
} else if (this._lastRecommend) {
|
||
dest = `${this._lastRecommend.lat},${this._lastRecommend.lng}`;
|
||
} else {
|
||
this.say('Ich habe noch keine Route. Sag mir wohin.');
|
||
return;
|
||
}
|
||
}
|
||
// payload.text wird bewusst NICHT vorab gesprochen - die requestRoute-Antwort
|
||
// mit km/min ist informativer und kommt 1-2s später.
|
||
|
||
this.requestRoute(origin, dest, payload.mode || 'driving');
|
||
return;
|
||
}
|
||
|
||
// Empfehlung: zoom auf den genannten Ort aus der aktuellen POI-Liste,
|
||
// sonst Geocoding via Google damit auch halluzinierte Namen aufgelöst werden.
|
||
if (payload.action === 'recommend' && payload.place) {
|
||
const target = this.findPoiByName(payload.place);
|
||
if (target) {
|
||
this.flyToPoi(target);
|
||
this._lastRecommend = { name: target.name, lat: target.lat, lng: target.lng, address: target.address };
|
||
this.notify(`★ ${target.name}`);
|
||
if (payload.text) this.say(payload.text);
|
||
} else {
|
||
console.warn('[Fox HUD] recommend: place nicht in poiList → Geocoding-Versuch:', payload.place);
|
||
this.geocodeAndZoom(payload.place, payload.text);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Code-Antwort → Terminal Panel
|
||
if (payload.action === 'code') {
|
||
this.showCodePanel({
|
||
description: payload.text || '',
|
||
code: payload.code || '',
|
||
language: payload.language || 'php',
|
||
files: payload.files || [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Rechenergebnis
|
||
if (payload.action === 'calculate') {
|
||
if (payload.result) this.notify('= ' + payload.result);
|
||
if (payload.text) this.notify(payload.text);
|
||
return;
|
||
}
|
||
|
||
// Spotify
|
||
if (payload.action === 'spotify_play') { window.foxBridge?.macCommand('spotify_play'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'spotify_pause') { window.foxBridge?.macCommand('spotify_pause'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'spotify_next') { window.foxBridge?.macCommand('spotify_next'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'spotify_prev') { window.foxBridge?.macCommand('spotify_prev'); if (payload.text) this.notify(payload.text); return; }
|
||
|
||
// Desktop Actions
|
||
if (payload.action === 'mail_open') { window.foxBridge?.macCommand('mail_open'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'volume_up') { window.foxBridge?.macCommand('volume_up'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'volume_down') { window.foxBridge?.macCommand('volume_down'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'screenshot') { window.foxBridge?.macCommand('screenshot'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'save_desktop') { window.foxBridge?.macCommand('save_desktop'); if (payload.text) this.notify(payload.text); return; }
|
||
if (payload.action === 'open_app' && payload.app) {
|
||
window.foxBridge?.macCommand('app:' + payload.app);
|
||
if (payload.text) this.notify(payload.text);
|
||
return;
|
||
}
|
||
|
||
if (['map','food','weather','transit','poi'].includes(payload.action)) {
|
||
let city = payload.data;
|
||
if (!city && payload.city) {
|
||
city = this.cities[payload.city.toLowerCase().trim()];
|
||
if (!city) console.warn('[Fox HUD] Stadt unbekannt:', payload.city);
|
||
}
|
||
if (!city && payload.action === 'poi') {
|
||
if (this.mapVisible) {
|
||
city = { lng: this.currentLatLng.lng, lat: this.currentLatLng.lat, zoom: 14, name: 'Hier', bearing: 0 };
|
||
} else if (this.homeCity) {
|
||
city = this.homeCity;
|
||
}
|
||
}
|
||
// POI: Karte NICHT automatisch öffnen — nur auf explizite Karten-Anfrage
|
||
if (city && payload.action !== 'poi') {
|
||
this.flyTo(city);
|
||
if (payload.action === 'transit') {
|
||
setTimeout(() => this.enableTransit(), 4500);
|
||
}
|
||
}
|
||
if (payload.action === 'poi' && payload.category) {
|
||
const label = payload.label || payload.category;
|
||
const autoRec = !!payload.auto_recommend;
|
||
const searchLat = city?.lat || this.currentLatLng.lat;
|
||
const searchLng = city?.lng || this.currentLatLng.lng;
|
||
if (!autoRec) this.say(`Ich suche ${label}.`);
|
||
setTimeout(() => {
|
||
this.searchPois(payload.category, label, searchLat, searchLng, 2000, payload.cuisine || null, autoRec);
|
||
}, 100);
|
||
}
|
||
// Map/Weather/Transit haben keinen eigenen Spruch - dann sagen wir den Reply-Text
|
||
if (payload.text && ['map','weather','transit'].includes(payload.action)) {
|
||
this.say(payload.text);
|
||
}
|
||
}
|
||
if (payload.text) this.notify(payload.text);
|
||
},
|
||
|
||
flyTo(city) {
|
||
if (!city || typeof city.lng !== 'number') return;
|
||
console.log('[Fox HUD] flyTo:', city.name, city);
|
||
|
||
if (!this.map) this.initMap();
|
||
|
||
this.mapVisible = true;
|
||
this.cityName = city.name || null;
|
||
this.currentLatLng = { lat: city.lat, lng: city.lng };
|
||
this.currentCenter = [city.lng, city.lat];
|
||
this.spawnZoomRings();
|
||
this.notify(`→ ${city.name}`);
|
||
|
||
// Map-Layer hart sichtbar setzen - Body-Klasse PLUS direkte Styles,
|
||
// damit jede Visibility-Reset-Variante (Alpine, Livewire-Morph) abgefangen ist.
|
||
document.body.classList.add('map-visible');
|
||
const ml = document.getElementById('map-layer');
|
||
if (ml) {
|
||
ml.style.display = 'block';
|
||
ml.style.opacity = '1';
|
||
ml.style.pointerEvents = 'auto';
|
||
ml.style.visibility = 'visible';
|
||
ml.classList.add('show');
|
||
}
|
||
document.getElementById('fox-center')?.classList.add('hide');
|
||
document.getElementById('fox-mini')?.classList.add('show');
|
||
|
||
// 200ms warten damit der Browser die Layout-Änderungen committed hat,
|
||
// dann erst resize() (sonst rechnet MapLibre auf alter Container-Größe).
|
||
setTimeout(() => {
|
||
if (!this.map) return;
|
||
try { this.map.resize(); } catch (e) {}
|
||
this.map.flyTo({
|
||
center: [city.lng, city.lat],
|
||
zoom: city.zoom,
|
||
pitch: 50,
|
||
bearing: city.bearing || 0,
|
||
duration: 8000,
|
||
curve: 2.4,
|
||
speed: 0.3,
|
||
screenSpeed: 0.6,
|
||
essential: true,
|
||
});
|
||
}, 200);
|
||
this.updateHudState();
|
||
},
|
||
|
||
closeMap() {
|
||
// Alles aufräumen - egal in welchem Zustand wir sind.
|
||
this.clearPois();
|
||
this.removeRouteLayer();
|
||
this.routeStepsVisible = false;
|
||
this.routeInfoVisible = false;
|
||
this.statusVisible = false;
|
||
this.statusLines = [];
|
||
this.placeInfoVisible = false;
|
||
this.placeInfo = null;
|
||
this.placeDesk = { visible: false, data: null, liftedCard: null };
|
||
document.body.classList.remove('map-visible', 'poi-visible', 'route-visible');
|
||
this.mapVisible = false;
|
||
this.cancelFollowup();
|
||
this._pendingConfirm = null;
|
||
|
||
// Inline-Styles vom flyTo zurücksetzen - sonst überschreiben sie CSS
|
||
// und der Map-Layer bleibt sichtbar trotz body-Klasse weg.
|
||
const ml = document.getElementById('map-layer');
|
||
if (ml) {
|
||
ml.style.opacity = '0';
|
||
ml.style.pointerEvents = 'none';
|
||
ml.classList.remove('show');
|
||
// display/visibility erst nach Fade-Out wegnehmen
|
||
setTimeout(() => {
|
||
if (!this.mapVisible) {
|
||
ml.style.display = 'none';
|
||
ml.style.visibility = 'hidden';
|
||
}
|
||
}, 850);
|
||
}
|
||
|
||
if (this.map) {
|
||
try {
|
||
this.map.flyTo({ zoom: 2, pitch: 0, bearing: 0, duration: 1800, curve: 2.0, essential: true });
|
||
} catch (e) { /* egal */ }
|
||
}
|
||
this.updateHudState();
|
||
},
|
||
|
||
spawnZoomRings() {
|
||
const c = this.$root.querySelector('#zoom-rings');
|
||
if (!c) return;
|
||
for (let i = 0; i < 3; i++) {
|
||
setTimeout(() => {
|
||
const r = document.createElement('div');
|
||
r.className = 'zring';
|
||
c.appendChild(r);
|
||
setTimeout(() => r.remove(), 1700);
|
||
}, i * 400);
|
||
}
|
||
},
|
||
|
||
_smoothRateAnim(anim, target, ms) {
|
||
const dur = anim.effect?.getComputedTiming()?.duration ?? 0;
|
||
const ct = anim.currentTime ?? 0;
|
||
if (dur > 0) {
|
||
// Going backward: bump currentTime up by 100 cycles so it never hits 0.
|
||
// Adding exact multiples of duration is visually imperceptible (periodic anim).
|
||
if (target < 0 && ct < dur * 20) anim.currentTime = ct + dur * 100;
|
||
// Already slipped negative: reset to mid-cycle.
|
||
else if (ct < 0) anim.currentTime = dur * 0.5;
|
||
}
|
||
const from = anim.playbackRate;
|
||
const t0 = performance.now();
|
||
const step = (now) => {
|
||
const p = Math.min((now - t0) / ms, 1);
|
||
const e = p < 0.5 ? 2*p*p : 1 - Math.pow(-2*p + 2, 2) / 2;
|
||
anim.playbackRate = from + (target - from) * e;
|
||
if (p < 1) requestAnimationFrame(step);
|
||
};
|
||
requestAnimationFrame(step);
|
||
},
|
||
|
||
_smoothRate(elements, targetRate, ms) {
|
||
elements.forEach(el => {
|
||
if (!el) return;
|
||
el.getAnimations().forEach(a => this._smoothRateAnim(a, targetRate, ms));
|
||
});
|
||
},
|
||
|
||
_startArcRandom() {
|
||
this._arcRnd = true;
|
||
const el = this.$el;
|
||
const schedule = (sel, minMs, maxMs) => {
|
||
if (!this._arcRnd) return;
|
||
setTimeout(() => {
|
||
if (!this._arcRnd) return;
|
||
el.querySelector(sel)?.getAnimations().forEach(a => {
|
||
this._smoothRateAnim(a, Math.random() > 0.5 ? 1 : -1, 500 + Math.random() * 600);
|
||
});
|
||
schedule(sel, minMs, maxMs);
|
||
}, minMs + Math.random() * (maxMs - minMs));
|
||
};
|
||
schedule('.fox-arc-top', 500, 1800);
|
||
setTimeout(() => schedule('.fox-arc-bot', 500, 1800), 300);
|
||
setTimeout(() => schedule('.fox-arc-mid', 500, 1800), 600);
|
||
},
|
||
|
||
_stopArcRandom() { this._arcRnd = false; },
|
||
|
||
closePlaceDesk() {
|
||
this.placeDesk = { visible: false, data: null, liftedCard: null };
|
||
},
|
||
toggleDeskCard(type) { this.placeDesk.liftedCard = (this.placeDesk.liftedCard === type) ? null : type; },
|
||
putDeskCardDown() { this.placeDesk.liftedCard = null; },
|
||
|
||
flashEdge() { this.edgeGlow = true; setTimeout(() => { this.edgeGlow = false; }, 1500); },
|
||
|
||
notify(text) {
|
||
const c = this.$refs.notif;
|
||
if (!c) return;
|
||
const el = document.createElement('div');
|
||
el.className = 'notif';
|
||
el.textContent = text.length > 180 ? text.slice(0, 180) + '…' : text;
|
||
c.appendChild(el);
|
||
setTimeout(() => { el.style.opacity = 0; setTimeout(() => el.remove(), 400); }, 6000);
|
||
},
|
||
|
||
showCodePanel(data) {
|
||
this.codePanel.visible = true;
|
||
this.codePanel.typing = true;
|
||
this.codePanel.typingText = 'Fox schreibt...';
|
||
this.codePanel.description = data.description || '';
|
||
this.codePanel.language = data.language || 'php';
|
||
this.codePanel.title = `fox@terminal — ${data.language || 'code'}`;
|
||
this.codePanel.files = data.files || [];
|
||
this.codePanel.activeFile = 0;
|
||
this.codeCopied = false;
|
||
|
||
setTimeout(() => {
|
||
this.codePanel.typing = false;
|
||
this.codePanel.code = data.code || '';
|
||
}, 800);
|
||
},
|
||
|
||
closeCodePanel() {
|
||
this.codePanel.visible = false;
|
||
this.codePanel.code = '';
|
||
this.codePanel.files = [];
|
||
this.codeCopied = false;
|
||
},
|
||
|
||
copyCode() {
|
||
const code = this.codePanel.files.length > 1
|
||
? (this.codePanel.files[this.codePanel.activeFile]?.code || this.codePanel.code)
|
||
: this.codePanel.code;
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
this.codeCopied = true;
|
||
setTimeout(() => { this.codeCopied = false; }, 2000);
|
||
});
|
||
},
|
||
|
||
selectFile(index) {
|
||
this.codePanel.activeFile = index;
|
||
this.codePanel.code = this.codePanel.files[index]?.code || '';
|
||
this.codePanel.language = this.codePanel.files[index]?.language || 'php';
|
||
},
|
||
|
||
async openBrain() {
|
||
this.brainVisible = true;
|
||
document.body.classList.add('brain-visible');
|
||
const info = document.getElementById('brain-info');
|
||
if (info) info.style.display = 'block';
|
||
document.getElementById('brain-popup') && (document.getElementById('brain-popup').style.display = 'none');
|
||
|
||
await new Promise(r => setTimeout(r, 350));
|
||
|
||
if (!this._brainInit && window.initFoxBrain) {
|
||
this._brainInit = true;
|
||
try {
|
||
await window.initFoxBrain('/fox/brain/graph', (stats) => {
|
||
this.brainStats = stats;
|
||
const n = document.getElementById('bi-nodes');
|
||
const e = document.getElementById('bi-edges');
|
||
const p = document.getElementById('bi-pending');
|
||
if (n) n.textContent = stats.nodes;
|
||
if (e) e.textContent = stats.edges;
|
||
if (p) p.textContent = stats.pending;
|
||
});
|
||
} catch(err) {
|
||
console.error('[FoxBrain] init failed:', err);
|
||
this._brainInit = false;
|
||
}
|
||
} else if (!window.initFoxBrain) {
|
||
console.error('[FoxBrain] window.initFoxBrain not defined');
|
||
}
|
||
},
|
||
|
||
closeBrain() {
|
||
this.brainVisible = false;
|
||
document.body.classList.remove('brain-visible');
|
||
const info = document.getElementById('brain-info');
|
||
if (info) info.style.display = 'none';
|
||
document.getElementById('brain-popup') && (document.getElementById('brain-popup').style.display = 'none');
|
||
if (window.destroyFoxBrain) { window.destroyFoxBrain(); this._brainInit = false; }
|
||
},
|
||
|
||
escapeHtml(text) {
|
||
return String(text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
},
|
||
|
||
// ─── POI-Suche ─────────────────────────────────────────
|
||
async searchPois(category, label, lat, lng, radius = 2000, cuisine = null, autoRecommend = false) {
|
||
this.notify(`Suche ${label || category} …`);
|
||
|
||
await this.fadeOutPois();
|
||
this.poiList = [];
|
||
this.poiLabel = label || category;
|
||
this.poiLoading = true;
|
||
document.body.classList.add('poi-visible');
|
||
|
||
const filterMap = {
|
||
restaurant: '["amenity"="restaurant"]',
|
||
cafe: '["amenity"="cafe"]',
|
||
bar: '["amenity"~"^(bar|pub|nightclub)$"]',
|
||
fast_food: '["amenity"="fast_food"]',
|
||
pharmacy: '["amenity"="pharmacy"]',
|
||
hospital: '["amenity"~"^(hospital|clinic)$"]',
|
||
doctor: '["amenity"="doctors"]',
|
||
bank: '["amenity"~"^(bank|atm)$"]',
|
||
fuel: '["amenity"="fuel"]',
|
||
supermarket: '["shop"~"^(supermarket|convenience)$"]',
|
||
hotel: '["tourism"~"^(hotel|hostel|guest_house)$"]',
|
||
government: '["amenity"~"^(townhall|courthouse|public_building)$"]',
|
||
police: '["amenity"="police"]',
|
||
parking: '["amenity"="parking"]',
|
||
};
|
||
const baseFilter = filterMap[category] || filterMap.restaurant;
|
||
|
||
// Mehrere Overpass-Mirrors - der erste der binnen 8s antwortet, gewinnt.
|
||
const MIRRORS = [
|
||
'https://overpass-api.de/api/interpreter',
|
||
'https://overpass.kumi.systems/api/interpreter',
|
||
'https://overpass.openstreetmap.fr/api/interpreter',
|
||
'https://overpass.private.coffee/api/interpreter',
|
||
];
|
||
const buildQuery = (cf, r) => `[out:json][timeout:8];(node${baseFilter}${cf}(around:${r},${lat},${lng}););out body 50;`;
|
||
|
||
const tryOverpass = async (q) => {
|
||
for (const url of MIRRORS) {
|
||
try {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), 8500);
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
body: 'data=' + encodeURIComponent(q),
|
||
signal: ctrl.signal,
|
||
});
|
||
clearTimeout(t);
|
||
if (!res.ok) { console.warn('[Fox HUD] Overpass', url, 'HTTP', res.status); continue; }
|
||
const data = await res.json();
|
||
return (data.elements || [])
|
||
.filter(e => e.tags?.name)
|
||
.slice(0, 50)
|
||
.map(e => ({
|
||
id: 'poi-' + e.id, name: e.tags.name,
|
||
address: this.formatAddress(e.tags),
|
||
category, lat: e.lat, lng: e.lon,
|
||
}));
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] Overpass', url, 'failed:', e.name);
|
||
// weiter zum nächsten Mirror
|
||
}
|
||
}
|
||
return null; // alle Mirrors tot
|
||
};
|
||
|
||
// Letzter Rettungsanker: Google Places via Backend.
|
||
const tryGooglePlaces = async (r) => {
|
||
try {
|
||
const res = await fetch('/fox/places', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({ category, lat, lng, radius: r, cuisine, label }),
|
||
});
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
return (data.pois || []).map(p => ({ ...p, category }));
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] Google Places fallback failed:', e);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
try {
|
||
let pois = null;
|
||
|
||
// 1. Overpass mit Cuisine-Filter
|
||
pois = await tryOverpass(buildQuery(cuisine ? `["cuisine"~"${cuisine}"]` : '', radius));
|
||
// 2. Overpass ohne Cuisine
|
||
if ((!pois || pois.length === 0) && cuisine) pois = await tryOverpass(buildQuery('', radius));
|
||
// 3. Overpass größerer Radius
|
||
if (!pois || pois.length === 0) pois = await tryOverpass(buildQuery('', radius * 3));
|
||
// 4. Google Places (Cloud) - garantierter Treffer wenn Daten vorhanden
|
||
if (!pois || pois.length === 0) {
|
||
console.log('[Fox HUD] Overpass tot oder leer, fallback auf Google Places');
|
||
this.notify(`Suche länger als gewohnt - hole Daten direkt`);
|
||
pois = await tryGooglePlaces(radius);
|
||
}
|
||
|
||
this.poiLoading = false;
|
||
|
||
if (!pois || pois.length === 0) {
|
||
this.notify(`Keine ${label} im Umkreis gefunden`);
|
||
this.say(`Ich habe leider keine ${label} in der Nähe gefunden. Probier es mit einem größeren Suchradius.`);
|
||
return;
|
||
}
|
||
|
||
this.poiList = pois;
|
||
if (this.mapVisible && this.map) this.renderPoisOnMapStaggered(pois);
|
||
this.notify(`${pois.length} ${label} geladen`);
|
||
this.persistPoiContext(category, label, pois);
|
||
|
||
if (autoRecommend) {
|
||
// Ersten Treffer (Google sortiert nach Relevanz, OSM nach Distanz) als Empfehlung
|
||
const pick = pois.sort((a, b) => (b.rating || 0) - (a.rating || 0))[0] || pois[0];
|
||
setTimeout(() => {
|
||
this.flyToPoi(pick);
|
||
this._lastRecommend = { name: pick.name, lat: pick.lat, lng: pick.lng, address: pick.address };
|
||
this.notify(`★ ${pick.name}`);
|
||
const ratingNote = pick.rating ? ` - hat ${String(pick.rating).replace('.', ',')} Sterne` : '';
|
||
const intro = this.pick(this.phrases.recommendIntro);
|
||
this.say(`${intro} ${pick.name}${pick.address ? ' in der ' + pick.address : ''}${ratingNote}.`);
|
||
// Detail-Desk automatisch öffnen mit Google Places Daten
|
||
const infoQuery = pick.name + (this.homeCity?.name ? ', ' + this.homeCity.name : '');
|
||
setTimeout(() => this.showPlaceInfo(infoQuery), 2000);
|
||
// Followup nur einmal pro Stunde - nicht jedesmal nervig.
|
||
const lastAsk = this._followupLastAskAt || 0;
|
||
if (Date.now() - lastAsk > 60 * 60 * 1000) {
|
||
this.queueFollowup(
|
||
'Soll ich dir die Route dahin rechnen?',
|
||
6500,
|
||
() => this.requestRoute(
|
||
this._userLocation ? `${this._userLocation.lat},${this._userLocation.lng}` : `${this.currentLatLng.lat},${this.currentLatLng.lng}`,
|
||
`${pick.lat},${pick.lng}`,
|
||
'driving',
|
||
),
|
||
);
|
||
}
|
||
}, 1500);
|
||
} else {
|
||
this.say(`Ich habe ${pois.length} ${label} gefunden.`);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[Fox HUD] searchPois unrecoverable:', err);
|
||
this.poiLoading = false;
|
||
this.notify('Alle Datenquellen offline');
|
||
this.say('Sowohl OpenStreetMap als auch Google sind grad nicht erreichbar. Versuch es in einer Minute nochmal.');
|
||
}
|
||
},
|
||
|
||
// Letzte POI-Treffer ans Backend (Cache) - damit Fox sie als
|
||
// Kontext fürs LLM nutzen kann ("empfehl mir eins").
|
||
async persistPoiContext(category, label, pois) {
|
||
try {
|
||
await fetch('/fox/poi-context', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({
|
||
category, label,
|
||
city: this.poiLabel || '',
|
||
pois: pois.map(p => ({
|
||
name: p.name, address: p.address || '',
|
||
lat: p.lat, lng: p.lng,
|
||
})),
|
||
}),
|
||
});
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] poi-context POST fehlgeschlagen:', e);
|
||
}
|
||
},
|
||
|
||
async say(text) {
|
||
if (!text) return;
|
||
// Generation-Token: wenn ein neuerer say() startet, verwirft das den älteren
|
||
// auch wenn dessen Fetch erst jetzt zurückkommt.
|
||
this._sayGen = (this._sayGen || 0) + 1;
|
||
const myGen = this._sayGen;
|
||
try {
|
||
if (this._sayAudio) { this._sayAudio.pause(); this._sayAudio.src = ''; this._sayAudio = null; }
|
||
if (this._sayAbort) this._sayAbort.abort();
|
||
this._sayAbort = new AbortController();
|
||
|
||
const res = await fetch('/fox/tts', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({ text }),
|
||
signal: this._sayAbort.signal,
|
||
});
|
||
if (myGen !== this._sayGen) return; // veraltet
|
||
if (!res.ok) return console.warn('[Fox HUD] /fox/tts HTTP', res.status);
|
||
const data = await res.json();
|
||
if (myGen !== this._sayGen) return; // veraltet
|
||
if (!data?.audio_url) return;
|
||
|
||
const audio = new Audio(data.audio_url);
|
||
audio.addEventListener('play', () => { if (myGen === this._sayGen) this.isSpeaking = true; });
|
||
audio.addEventListener('ended', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
|
||
audio.addEventListener('pause', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
|
||
audio.addEventListener('error', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
|
||
this._sayAudio = audio;
|
||
audio.play().catch(err => { this.isSpeaking = false; console.warn('[Fox HUD] audio:', err.name); });
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') console.warn('[Fox HUD] say():', e);
|
||
}
|
||
},
|
||
|
||
async fadeOutPois() {
|
||
if (this.poiList.length === 0 && !this.map?.getLayer('pois-layer')) return;
|
||
if (this.map?.getLayer('pois-layer')) {
|
||
this.map.setPaintProperty('pois-layer', 'circle-opacity-transition', { duration: 300 });
|
||
this.map.setPaintProperty('pois-glow', 'circle-opacity-transition', { duration: 300 });
|
||
this.map.setPaintProperty('pois-layer', 'circle-opacity', 0);
|
||
this.map.setPaintProperty('pois-glow', 'circle-opacity', 0);
|
||
}
|
||
document.querySelectorAll('.poi-item').forEach(el => el.classList.add('fading-out'));
|
||
await new Promise(r => setTimeout(r, 320));
|
||
this.removePoiLayer();
|
||
},
|
||
|
||
formatAddress(tags) {
|
||
const parts = [];
|
||
if (tags['addr:street']) parts.push(tags['addr:street'] + (tags['addr:housenumber'] ? ' ' + tags['addr:housenumber'] : ''));
|
||
if (tags['addr:postcode']) parts.push(tags['addr:postcode']);
|
||
if (tags.cuisine) parts.push('· ' + tags.cuisine);
|
||
return parts.join(' · ') || (tags.amenity || tags.shop || tags.tourism || '');
|
||
},
|
||
|
||
renderPoisOnMapStaggered(pois) {
|
||
if (!this.map) return;
|
||
this.removePoiLayer();
|
||
this.map.addSource('pois', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||
this.map.addLayer({
|
||
id: 'pois-glow', type: 'circle', source: 'pois',
|
||
paint: {
|
||
'circle-radius': 14, 'circle-color': '#f59e0b',
|
||
'circle-opacity': 0.25, 'circle-blur': 0.6,
|
||
'circle-opacity-transition': { duration: 350 },
|
||
},
|
||
});
|
||
this.map.addLayer({
|
||
id: 'pois-layer', type: 'circle', source: 'pois',
|
||
paint: {
|
||
'circle-radius': 6, 'circle-color': '#f59e0b',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||
'circle-opacity-transition': { duration: 350 },
|
||
},
|
||
});
|
||
this.map.on('click', 'pois-layer', e => {
|
||
const f = e.features[0];
|
||
new maplibregl.Popup({ closeButton: false, offset: 14 })
|
||
.setLngLat(f.geometry.coordinates)
|
||
.setHTML(`<b>${f.properties.name}</b>${f.properties.address ? '<br>' + f.properties.address : ''}`)
|
||
.addTo(this.map);
|
||
});
|
||
const features = [];
|
||
pois.forEach((p, i) => {
|
||
setTimeout(() => {
|
||
features.push({
|
||
type: 'Feature',
|
||
properties: { id: p.id, name: p.name, address: p.address },
|
||
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
|
||
});
|
||
if (this.map?.getSource('pois')) {
|
||
this.map.getSource('pois').setData({ type: 'FeatureCollection', features: features.slice() });
|
||
}
|
||
}, i * 60);
|
||
});
|
||
},
|
||
|
||
removePoiLayer() {
|
||
['pois-layer', 'pois-glow'].forEach(id => {
|
||
if (this.map?.getLayer(id)) this.map.removeLayer(id);
|
||
});
|
||
if (this.map?.getSource('pois')) this.map.removeSource('pois');
|
||
},
|
||
|
||
// Findet einen POI in der aktuellen Liste über fuzzy name match.
|
||
findPoiByName(name) {
|
||
if (!name || !this.poiList?.length) return null;
|
||
const norm = (s) => (s || '').toLowerCase()
|
||
.normalize('NFD').replace(/[̀-ͯ]/g, '') // diakritika weg
|
||
.replace(/[''`´]/g, "'")
|
||
.replace(/[^a-z0-9 ]/g, ' ')
|
||
.replace(/\s+/g, ' ').trim();
|
||
const t = norm(name);
|
||
if (!t) return null;
|
||
|
||
// 1. Exact normalized
|
||
let hit = this.poiList.find(p => norm(p.name) === t);
|
||
if (hit) return hit;
|
||
// 2. Substring beider Richtungen
|
||
hit = this.poiList.find(p => norm(p.name).includes(t) || t.includes(norm(p.name)));
|
||
if (hit) return hit;
|
||
// 3. Token-Overlap: mind. 1 nicht-trivialer Begriff stimmt überein
|
||
const tokens = new Set(t.split(' ').filter(w => w.length > 2));
|
||
if (tokens.size) {
|
||
hit = this.poiList.find(p => {
|
||
const pTokens = norm(p.name).split(' ').filter(w => w.length > 2);
|
||
return pTokens.some(pt => tokens.has(pt));
|
||
});
|
||
if (hit) return hit;
|
||
}
|
||
// 4. Diagnose-Output: zeig was wir gesucht und was wir hatten
|
||
console.warn('[Fox HUD] kein Treffer für "'+name+'" in poiList - Kandidaten:',
|
||
this.poiList.slice(0, 8).map(p => p.name));
|
||
|
||
return null;
|
||
},
|
||
|
||
// Menschliche Erinnerungssätze - variiert + bezieht sich auf Inhalt
|
||
buildReminderSentence(what) {
|
||
if (!what || what === 'Erinnerung') {
|
||
return this.pick([
|
||
'Hey, dein Timer ist abgelaufen.',
|
||
'Zeit ist um.',
|
||
'Der Wecker geht.',
|
||
]);
|
||
}
|
||
const w = what.toLowerCase();
|
||
// Kontextabhängige Templates
|
||
if (/ess|hunger|nahrung|frühstück|mittag|abendessen|snack/.test(w)) {
|
||
return this.pick([
|
||
`Hey, du wolltest doch was essen — ${what}.`,
|
||
`Magenknurren-Alarm: ${what}.`,
|
||
`Vergiss nicht zu essen, du wolltest ${what}.`,
|
||
]);
|
||
}
|
||
if (/wäsche|waschen|wasch|trockner|bügeln/.test(w)) {
|
||
return this.pick([
|
||
`Du wolltest doch ${what}, jetzt wäre der richtige Moment.`,
|
||
`Wäsche-Reminder: ${what}.`,
|
||
]);
|
||
}
|
||
if (/wasser|trinken|kaffee|tee/.test(w)) {
|
||
return this.pick([
|
||
`Vergiss nicht zu trinken — ${what}.`,
|
||
`Kurzer Hinweis: ${what} steht an.`,
|
||
]);
|
||
}
|
||
if (/pause|aufstehen|bewegen|sport/.test(w)) {
|
||
return this.pick([
|
||
`Zeit für ${what}.`,
|
||
`Hey, kleine ${what} würde dir guttun.`,
|
||
]);
|
||
}
|
||
if (/anruf|telefon|call|meeting|termin/.test(w)) {
|
||
return this.pick([
|
||
`Termin-Erinnerung: ${what}.`,
|
||
`${what} steht an.`,
|
||
]);
|
||
}
|
||
return this.pick([
|
||
`Du wolltest doch ${what} — jetzt ist der Moment.`,
|
||
`Hey, ${what} steht an.`,
|
||
`Erinnerung: ${what}. Du hast mich darum gebeten.`,
|
||
`Vergiss nicht: ${what}.`,
|
||
]);
|
||
},
|
||
|
||
formatDelay(secs) {
|
||
if (secs < 60) return secs + 's';
|
||
if (secs < 3600) return Math.round(secs / 60) + 'min';
|
||
return Math.round(secs / 3600) + 'h';
|
||
},
|
||
|
||
async runSystemStatus() {
|
||
this.statusVisible = true;
|
||
this.statusScanning = true;
|
||
this.statusLines = [];
|
||
this.say(this.pick([
|
||
'Diagnose läuft.',
|
||
'System-Check eingeleitet.',
|
||
'Scanne Subsysteme.',
|
||
'Status-Audit gestartet.',
|
||
]));
|
||
|
||
const append = async (label, value, ok = true, delay = 180) => {
|
||
await new Promise(r => setTimeout(r, delay));
|
||
this.statusLines.push({ label, value, ok });
|
||
};
|
||
|
||
await append('PROBE', 'Initializing diagnostic sweep…', true, 80);
|
||
|
||
let data = null;
|
||
try {
|
||
const res = await fetch('/fox/status');
|
||
if (res.ok) data = await res.json();
|
||
} catch (e) { /* ignore */ }
|
||
|
||
if (!data) {
|
||
this.statusScanning = false;
|
||
await append('FATAL', 'Status-Endpoint unreachable', false);
|
||
return;
|
||
}
|
||
|
||
const sys = data.system, svc = data.services, fox = data.fox;
|
||
|
||
await append('SYS·CPU', sys.cpu + '%', sys.cpu < 90);
|
||
await append('SYS·RAM', (sys.memory.used_mb || 0) + ' MB used', (sys.memory.percent || 0) < 92);
|
||
await append('SYS·DISK', (sys.disk.percent || 0) + '%', (sys.disk.percent || 0) < 92);
|
||
await append('SYS·LOAD', (sys.load['1m'] || 0).toFixed(2), true);
|
||
await append('SYS·UPTIME', sys.uptime.human, true);
|
||
|
||
await append('LLM·OLLAMA', svc.ollama.online ? 'ONLINE · ' + svc.ollama.models + ' models' : 'OFFLINE', svc.ollama.online);
|
||
await append('LLM·OPENAI', svc.openai.online ? 'KEY OK' : 'NO KEY', svc.openai.online);
|
||
await append('SVC·REVERB', svc.reverb.online ? 'CONNECTED' : 'DOWN', svc.reverb.online);
|
||
await append('GEO·DIRECT', svc.google_directions.online ? 'OK' : 'FAIL', svc.google_directions.online);
|
||
await append('GEO·PLACES', svc.google_places.online ? 'OK' : 'FAIL', svc.google_places.online);
|
||
await append('GEO·GEOCODE', svc.google_geocoding.online ? 'OK' : 'FAIL', svc.google_geocoding.online);
|
||
await append('SVC·SEARXNG', svc.searxng.online ? 'READY' : 'OFFLINE', svc.searxng.online);
|
||
|
||
await append('FOX·VERSION', fox.version, true);
|
||
await append('FOX·VOICE', fox.mode + ' / ' + fox.voice, true);
|
||
if (fox.last_action) {
|
||
await append('FOX·LAST', fox.last_action.label + ' (' + fox.last_action.count + ')', true);
|
||
}
|
||
await append('QUEUE', svc.queue.len + ' jobs pending', svc.queue.len < 50);
|
||
|
||
this.statusScanning = false;
|
||
await new Promise(r => setTimeout(r, 200));
|
||
|
||
const downCount = this.statusLines.filter(l => !l.ok).length;
|
||
const summary = downCount === 0
|
||
? this.pick(['Alle Systeme grün.', 'Diagnose: alles im Lot.', 'Check abgeschlossen, alles läuft.'])
|
||
: (downCount + ' Subsystem' + (downCount === 1 ? '' : 'e') + ' offline. Schau dir die roten Zeilen an.');
|
||
this.say(summary);
|
||
await append('READY', summary, downCount === 0, 150);
|
||
},
|
||
|
||
closeStatus() {
|
||
this.statusVisible = false;
|
||
this.statusLines = [];
|
||
},
|
||
|
||
async showPlaceInfo(query) {
|
||
// Referenz auf letzte Empfehlung auflösen
|
||
if (query === '__LAST_RECOMMEND__') {
|
||
if (!this._lastRecommend) {
|
||
this.say(this.pick([
|
||
'Ich weiß nicht worauf du dich beziehst - frag nach was Konkretem.',
|
||
'Sag mir den Namen, dann zeig ich dir Infos.',
|
||
'Wozu genau? Sag mir den Ort.',
|
||
]));
|
||
return;
|
||
}
|
||
query = this._lastRecommend.name + (this.homeCity?.name ? ' ' + this.homeCity.name : '');
|
||
}
|
||
this.placeInfoLoading = true;
|
||
this.placeInfoVisible = true;
|
||
this.placeInfoExpanded = false;
|
||
this.placeInfo = { name: query, loading: true };
|
||
try {
|
||
const res = await fetch('/fox/place-info', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({
|
||
query,
|
||
bias_lat: this._userLocation?.lat || this.currentLatLng.lat,
|
||
bias_lng: this._userLocation?.lng || this.currentLatLng.lng,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
this.placeInfoLoading = false;
|
||
this.placeInfo = null;
|
||
this.placeInfoVisible = false;
|
||
this.say(`Zu ${query} hab ich nichts gefunden. Probier einen anderen Begriff.`);
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
this.placeInfoLoading = false;
|
||
this.placeInfo = data; // keep compat
|
||
this._lastRecommend = { name: data.name, lat: data.lat, lng: data.lng, address: data.address };
|
||
this.placeDesk = { visible: true, data, liftedCard: this._pendingDeskCard || null };
|
||
this._pendingDeskCard = null;
|
||
this.placeInfoVisible = false;
|
||
|
||
// Sprachzusammenfassung
|
||
const parts = [];
|
||
parts.push(data.name);
|
||
if (data.summary) parts.push(data.summary);
|
||
else if (data.address) parts.push('in ' + data.address.split(',')[0]);
|
||
if (data.rating) parts.push(`${String(data.rating).replace('.', ',')} Sterne bei ${data.rating_count || 0} Bewertungen`);
|
||
this.say(parts.join('. ').slice(0, 280));
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] showPlaceInfo:', e);
|
||
this.placeInfoLoading = false;
|
||
this.placeInfoVisible = false;
|
||
this.say('Da hat was nicht funktioniert. Versuch es nochmal.');
|
||
}
|
||
},
|
||
|
||
expandPlaceInfo() {
|
||
if (!this.placeInfo) {
|
||
this.say('Hab nichts wozu ich mehr zeigen könnte. Frag mich erstmal nach was.');
|
||
return;
|
||
}
|
||
this.placeInfoExpanded = true;
|
||
this.say(this.pick([
|
||
'Hier sind alle Details die ich habe.',
|
||
'Aufgeklappt - schau dir das an.',
|
||
'Mehr gibts hier.',
|
||
]));
|
||
},
|
||
|
||
closePlaceInfo() {
|
||
this.placeInfoVisible = false;
|
||
this.placeInfo = null;
|
||
this.placeInfoExpanded = false;
|
||
},
|
||
|
||
// City-Lookup via Google Geocoding - 1 Treffer: flyTo. Mehrere: Chip-Auswahl.
|
||
async lookupCity(query) {
|
||
this.notify(`Suche ${query}…`);
|
||
try {
|
||
const res = await fetch('/fox/cities', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({ query }),
|
||
});
|
||
if (!res.ok) {
|
||
this.say(`Den Ort ${query} kann ich nicht finden.`);
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
const results = data.results || [];
|
||
if (results.length === 0) {
|
||
this.say(`${query} hab ich nicht gefunden. Versuch einen anderen Namen.`);
|
||
return;
|
||
}
|
||
if (results.length === 1) {
|
||
this.flyToDynamicCity(results[0]);
|
||
return;
|
||
}
|
||
// Mehrere Treffer → Chips-Auswahl + Voice-Frage
|
||
this.cityChoices = results;
|
||
this.cityChoiceQuery = query;
|
||
this.cityChoicesVisible = true;
|
||
const opts = results.slice(0, 3).map(r => r.country || r.region || r.formatted).join(', ');
|
||
this.say(`${query} gibt es mehrfach. Welches: ${opts}? Klick einfach.`);
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] lookupCity', e);
|
||
this.say('Hat nicht geklappt, versuch es nochmal.');
|
||
}
|
||
},
|
||
|
||
flyToDynamicCity(c) {
|
||
const city = {
|
||
name: c.name + (c.country ? ', ' + c.country : ''),
|
||
lat: c.lat, lng: c.lng,
|
||
zoom: 13, bearing: 0,
|
||
};
|
||
this.cityChoicesVisible = false;
|
||
this.cityChoices = [];
|
||
this.flyTo(city);
|
||
this.notify(`→ ${city.name}`);
|
||
this.say(this.pick([
|
||
`Hier ist ${c.name}${c.country ? ' in ' + c.country : ''}.`,
|
||
`Schau, ${c.name}.`,
|
||
`${c.name} für dich.`,
|
||
]));
|
||
},
|
||
|
||
cancelCityChoice() {
|
||
this.cityChoicesVisible = false;
|
||
this.cityChoices = [];
|
||
},
|
||
|
||
// HUD-Zustand an Livewire pushen — Fox kennt damit was offen ist
|
||
updateHudState() {
|
||
const state = {
|
||
map_visible: !!this.mapVisible,
|
||
current_city: this.cityName || null,
|
||
poi_panel_open: (this.poiList?.length || 0) > 0,
|
||
poi_label: this.poiLabel || null,
|
||
poi_count: this.poiList?.length || 0,
|
||
transit_on: !!this.transitOn,
|
||
stops_on: !!this.stopsOn,
|
||
route_visible: !!this.routeStepsVisible,
|
||
route_target: this._lastRoute?.target || null,
|
||
status_visible: !!this.statusVisible,
|
||
place_info_visible: !!this.placeInfoVisible,
|
||
place_info_name: this.placeInfo?.name || null,
|
||
};
|
||
try { this.$wire?.set('hudState', state, false); } catch (_) {}
|
||
},
|
||
|
||
async geocodeAndZoom(query, sayText) {
|
||
try {
|
||
const cityHint = this.homeCity?.name || '';
|
||
const res = await fetch('/fox/geocode', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({
|
||
query: query + (cityHint ? ' ' + cityHint : ''),
|
||
bias_lat: this.currentLatLng.lat,
|
||
bias_lng: this.currentLatLng.lng,
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
if (sayText) { this.notify(sayText); this.say(sayText); }
|
||
this.notify(`Konnte "${query}" nicht finden.`);
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (data?.lat && data?.lng) {
|
||
const target = { name: query, lat: data.lat, lng: data.lng, address: data.address };
|
||
this.flyToPoi(target);
|
||
this._lastRecommend = target;
|
||
this.notify(`★ ${query}`);
|
||
if (sayText) this.say(sayText);
|
||
} else {
|
||
if (sayText) { this.notify(sayText); this.say(sayText); }
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] geocodeAndZoom failed:', e);
|
||
if (sayText) this.say(sayText);
|
||
}
|
||
},
|
||
|
||
flyToPoi(p) {
|
||
if (!this.map) return;
|
||
this.map.flyTo({ center: [p.lng, p.lat], zoom: 17, pitch: 55, duration: 2000, essential: true });
|
||
new maplibregl.Popup({ closeButton: false, offset: 14 })
|
||
.setLngLat([p.lng, p.lat])
|
||
.setHTML(`<b>${p.name}</b>${p.address ? '<br>' + p.address : ''}`)
|
||
.addTo(this.map);
|
||
},
|
||
|
||
// ─── Routenplanung (Google Directions via /fox/route) ─────────
|
||
async requestRoute(origin, destination, mode) {
|
||
document.body.classList.add('map-visible');
|
||
this.mapVisible = true;
|
||
if (!this.map) this.initMap();
|
||
|
||
const tryFetch = async () => {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), 12000);
|
||
try {
|
||
const res = await fetch('/fox/route', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||
},
|
||
body: JSON.stringify({ origin, destination, mode }),
|
||
signal: ctrl.signal,
|
||
});
|
||
clearTimeout(t);
|
||
return res;
|
||
} catch (e) {
|
||
clearTimeout(t);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
let res = await tryFetch();
|
||
let data = null;
|
||
let attempt = 1;
|
||
|
||
while ((!res || !res.ok) && attempt < 3) {
|
||
console.warn('[Fox HUD] route fail (attempt '+attempt+'), retry');
|
||
if (attempt === 1) this.say(this.pick(this.phrases.retrying));
|
||
await new Promise(r => setTimeout(r, 700));
|
||
attempt++;
|
||
res = await tryFetch();
|
||
}
|
||
|
||
if (!res || !res.ok) {
|
||
const err = res ? await res.json().catch(() => ({})) : { error: 'timeout' };
|
||
console.warn('[Fox HUD] route final fail', err);
|
||
this.notify('Keine Route nach mehreren Versuchen.');
|
||
this.say(this.pick(this.phrases.routeFailed));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
data = await res.json();
|
||
this.drawRoute(data);
|
||
|
||
const target = (data.end_address || destination).split(',')[0];
|
||
const modeLabel = { driving: 'Auto', walking: 'zu Fuß', bicycling: 'Rad', transit: 'ÖPNV' }[mode] || '';
|
||
|
||
// Persistente Anzeige speichern - kann via "wie lange brauche ich" wieder abgefragt werden.
|
||
// origin + destination merken damit Mode-Wechsel ("zu fuß"/"mit ubahn") neu rechnen kann.
|
||
this._lastRoute = {
|
||
target, mode, modeLabel,
|
||
distance_text: data.distance_text,
|
||
duration_text: data.duration_text,
|
||
origin, destination,
|
||
};
|
||
this.routeInfoLabel = `${target} · ${data.distance_text} · ${data.duration_text}${modeLabel ? ' · ' + modeLabel : ''}`;
|
||
this.routeInfoVisible = true;
|
||
|
||
// Schritt-für-Schritt-Liste rechts statt POI-Liste
|
||
this.routeSteps = data.steps || [];
|
||
this.routeStepsTarget = target;
|
||
this.routeStepsVisible = this.routeSteps.length > 0;
|
||
if (this.routeStepsVisible) {
|
||
document.body.classList.remove('poi-visible');
|
||
document.body.classList.add('route-visible');
|
||
}
|
||
|
||
const intros = [
|
||
`Bis ${target}: ${data.distance_text}, ${data.duration_text}.`,
|
||
`${target} - ${data.distance_text} und ${data.duration_text}.`,
|
||
`${data.duration_text} brauchst du, ${data.distance_text} sinds bis ${target}.`,
|
||
`Schau, ${target} ist ${data.distance_text} weg, ${data.duration_text}.`,
|
||
];
|
||
const msg = this.pick(intros);
|
||
this.notify(msg);
|
||
this.say(msg);
|
||
} catch (e) {
|
||
console.warn('[Fox HUD] requestRoute error:', e);
|
||
this.say('Hm, die Route ist da aber ich kann sie nicht zeichnen. Lade nochmal die Seite.');
|
||
}
|
||
},
|
||
|
||
// Followup-Frage nach kurzer Pause - bricht ab wenn User selbst tippt/spricht.
|
||
// Wenn User danach mit "ja" antwortet, läuft onConfirm() statt LLM.
|
||
queueFollowup(text, delayMs, onConfirm = null) {
|
||
if (this._followupTimer) clearTimeout(this._followupTimer);
|
||
this._followupTimer = setTimeout(() => {
|
||
if (this._followupTimer) {
|
||
this.say(text);
|
||
this.notify(text);
|
||
this._followupLastAskAt = Date.now();
|
||
this._pendingConfirm = onConfirm;
|
||
// Bestätigungsfenster: 30s
|
||
setTimeout(() => { this._pendingConfirm = null; }, 30000);
|
||
}
|
||
}, delayMs);
|
||
},
|
||
|
||
cancelFollowup() {
|
||
if (this._followupTimer) {
|
||
clearTimeout(this._followupTimer);
|
||
this._followupTimer = null;
|
||
}
|
||
},
|
||
|
||
// "ja"-Antwort auf Fox-Frage abfangen - sonst bekommt der LLM "ja bitte" ohne Kontext
|
||
// und macht eine neue Empfehlung statt die Route zu zeigen.
|
||
tryConsumeConfirmation(text) {
|
||
if (!this._pendingConfirm) return false;
|
||
const t = (text || '').toLowerCase().trim();
|
||
const yes = /^(ja|jo|jep|yep|yes|klar|sicher|gerne|natürlich|natuerlich|bitte|ja bitte|ja gerne|ok|okay|mach|machs|los)\b/.test(t);
|
||
const no = /^(nein|nö|noe|ne|nope|no|lass|nicht|spar|später|nachher)\b/.test(t);
|
||
|
||
if (yes) {
|
||
const action = this._pendingConfirm;
|
||
this._pendingConfirm = null;
|
||
action?.();
|
||
return true;
|
||
}
|
||
if (no) {
|
||
this._pendingConfirm = null;
|
||
this.say(this.pick(this.phrases.confirmNo));
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// "Wie lange brauche ich" / "Wie weit ist es" → letzte Route nochmal ansagen
|
||
announceLastRoute() {
|
||
if (!this._lastRoute) {
|
||
this.say(this.pick([
|
||
'Noch keine Route da. Sag mir wohin.',
|
||
'Wir haben noch keinen Weg geplant. Wo soll es hin?',
|
||
'Ich weiß noch nicht wohin - sag mirs einfach.',
|
||
]));
|
||
return true;
|
||
}
|
||
const r = this._lastRoute;
|
||
const ml = r.modeLabel ? ' mit ' + r.modeLabel : '';
|
||
const msg = this.pick([
|
||
`${r.duration_text} brauchst du, ${r.distance_text} bis ${r.target}${ml}.`,
|
||
`Bis ${r.target} sind es ${r.distance_text}, dauert ${r.duration_text}${ml}.`,
|
||
`${r.distance_text} und ${r.duration_text} bis ${r.target}${ml}.`,
|
||
]);
|
||
this.notify(msg);
|
||
this.say(msg);
|
||
return true;
|
||
},
|
||
|
||
drawRoute(data) {
|
||
if (!this.map) return;
|
||
const apply = () => {
|
||
this.removeRouteLayer();
|
||
this.map.addSource('route', { type: 'geojson', data: data.geojson });
|
||
this.map.addLayer({
|
||
id: 'route-bg', type: 'line', source: 'route',
|
||
paint: { 'line-color': '#0e7490', 'line-width': 8, 'line-opacity': 0.55, 'line-blur': 1 },
|
||
});
|
||
this.map.addLayer({
|
||
id: 'route-line', type: 'line', source: 'route',
|
||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||
paint: { 'line-color': '#22d3ee', 'line-width': 4, 'line-opacity': 0.95 },
|
||
});
|
||
if (data.start_location) {
|
||
this._routeMarkers = this._routeMarkers || [];
|
||
const start = new maplibregl.Marker({ color: '#10b981' })
|
||
.setLngLat([data.start_location.lng, data.start_location.lat])
|
||
.addTo(this.map);
|
||
const end = new maplibregl.Marker({ color: '#ef4444' })
|
||
.setLngLat([data.end_location.lng, data.end_location.lat])
|
||
.addTo(this.map);
|
||
this._routeMarkers.push(start, end);
|
||
}
|
||
if (data.bounds) {
|
||
this.map.fitBounds([
|
||
[data.bounds.southwest.lng, data.bounds.southwest.lat],
|
||
[data.bounds.northeast.lng, data.bounds.northeast.lat],
|
||
], { padding: 80, duration: 1500 });
|
||
}
|
||
};
|
||
if (this.mapLoaded) apply(); else this.map.once('load', apply);
|
||
},
|
||
|
||
removeRouteLayer() {
|
||
['route-line','route-bg'].forEach(id => {
|
||
if (this.map?.getLayer(id)) this.map.removeLayer(id);
|
||
});
|
||
if (this.map?.getSource('route')) this.map.removeSource('route');
|
||
(this._routeMarkers || []).forEach(m => m.remove());
|
||
this._routeMarkers = [];
|
||
this.routeInfoVisible = false;
|
||
this.routeStepsVisible = false;
|
||
this.routeSteps = [];
|
||
document.body.classList.remove('route-visible');
|
||
},
|
||
|
||
clearPois() {
|
||
this.poiList = [];
|
||
this.poiLabel = '';
|
||
document.body.classList.remove('poi-visible');
|
||
this.removePoiLayer();
|
||
this.updateHudState();
|
||
},
|
||
|
||
// ─── Transit Layer ─────────────────────────────────────
|
||
toggleTransit() { this.transitOn ? this.disableTransit() : this.enableTransit(); },
|
||
enableTransit() {
|
||
if (!this.map || !this.mapLoaded) return;
|
||
this.transitOn = true;
|
||
this.loadTransitForLocation(this.currentLatLng.lat, this.currentLatLng.lng);
|
||
},
|
||
disableTransit() { this.transitOn = false; this.removeTransitLayers(); this.updateHudState(); },
|
||
|
||
loadTransitForLocation(lat, lng) {
|
||
const isWien = Math.abs(lat - 48.2082) < 0.15 && Math.abs(lng - 16.3738) < 0.2;
|
||
if (isWien) {
|
||
this.addTransitGeoJSON(WIEN_UBAHN);
|
||
this.notify('Wien U-Bahn — U1 U2 U3 U4 U6');
|
||
return;
|
||
}
|
||
this.notify('Lade Transit-Daten …');
|
||
const query = `[out:json][timeout:25];
|
||
(relation["route"="subway"](${lat-0.3},${lng-0.5},${lat+0.3},${lng+0.5});
|
||
relation["route"="tram"](${lat-0.2},${lng-0.4},${lat+0.2},${lng+0.4}););
|
||
out geom;`;
|
||
fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: 'data=' + encodeURIComponent(query) })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const geojson = this.overpassToGeoJSON(data);
|
||
if (geojson.features.length === 0) return this.notify('Keine Transit-Daten gefunden');
|
||
this.addTransitGeoJSON(geojson);
|
||
this.notify(`${geojson.features.length} Linien geladen`);
|
||
})
|
||
.catch(() => this.notify('Transit-Daten nicht verfügbar'));
|
||
},
|
||
|
||
overpassToGeoJSON(data) {
|
||
const colors = { subway: '#6366f1', tram: '#f59e0b', rail: '#10b981', light_rail: '#06b6d4' };
|
||
return {
|
||
type: 'FeatureCollection',
|
||
features: (data.relations || []).map(rel => {
|
||
const coords = [];
|
||
(rel.members || []).forEach(m => {
|
||
if (m.type === 'way' && m.geometry) m.geometry.forEach(pt => coords.push([pt.lon, pt.lat]));
|
||
});
|
||
if (coords.length < 2) return null;
|
||
return {
|
||
type: 'Feature',
|
||
properties: {
|
||
line: rel.tags?.ref || rel.tags?.name || '?',
|
||
color: colors[rel.tags?.route] || '#6366f1',
|
||
name: rel.tags?.name || '',
|
||
},
|
||
geometry: { type: 'LineString', coordinates: coords },
|
||
};
|
||
}).filter(Boolean),
|
||
};
|
||
},
|
||
|
||
addTransitGeoJSON(geojson) {
|
||
this.removeTransitLayers();
|
||
this.map.addSource('transit', { type: 'geojson', data: geojson });
|
||
this.map.addLayer({
|
||
id: 'transit-bg', type: 'line', source: 'transit',
|
||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||
paint: { 'line-color': ['get', 'color'], 'line-width': 10, 'line-opacity': 0.2, 'line-blur': 5 },
|
||
});
|
||
this.map.addLayer({
|
||
id: 'transit-lines', type: 'line', source: 'transit',
|
||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.92 },
|
||
});
|
||
},
|
||
|
||
removeTransitLayers() {
|
||
['transit-lines', 'transit-bg'].forEach(id => {
|
||
if (this.map?.getLayer(id)) this.map.removeLayer(id);
|
||
});
|
||
if (this.map?.getSource('transit')) this.map.removeSource('transit');
|
||
},
|
||
|
||
// ─── Stops ─────────────────────────────────────────────
|
||
toggleStops() { this.stopsOn ? this.disableStops() : this.enableStops(); },
|
||
enableStops() {
|
||
if (!this.map || !this.mapLoaded) return;
|
||
this.stopsOn = true;
|
||
const isWien = Math.abs(this.currentLatLng.lat - 48.2082) < 0.15;
|
||
if (!isWien) {
|
||
this.notify('Haltestellen aktuell nur für Wien');
|
||
this.stopsOn = false;
|
||
return;
|
||
}
|
||
this.addStopsLayer();
|
||
},
|
||
disableStops() {
|
||
this.stopsOn = false;
|
||
if (this.map?.getLayer('stops-layer')) this.map.removeLayer('stops-layer');
|
||
if (this.map?.getSource('stops')) this.map.removeSource('stops');
|
||
},
|
||
addStopsLayer() {
|
||
const geojson = {
|
||
type: 'FeatureCollection',
|
||
features: WIEN_STOPS.map(s => ({
|
||
type: 'Feature',
|
||
properties: { name: s.name, line: s.line, color: s.color },
|
||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
|
||
})),
|
||
};
|
||
if (this.map.getSource('stops')) this.map.removeSource('stops');
|
||
this.map.addSource('stops', { type: 'geojson', data: geojson });
|
||
this.map.addLayer({
|
||
id: 'stops-layer', type: 'circle', source: 'stops',
|
||
paint: {
|
||
'circle-radius': 6,
|
||
'circle-color': ['get', 'color'],
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||
},
|
||
});
|
||
this.map.on('click', 'stops-layer', e => {
|
||
const p = e.features[0].properties;
|
||
new maplibregl.Popup({ closeButton: false })
|
||
.setLngLat(e.features[0].geometry.coordinates)
|
||
.setHTML(`<b>${p.name}</b><br>${p.line}`)
|
||
.addTo(this.map);
|
||
});
|
||
},
|
||
|
||
// ─── Input + Mikro ─────────────────────────────────────
|
||
onKey(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
// Wenn Fox grad eine Frage stellte und User "ja"/"nein" tippt → ohne LLM ausführen
|
||
const text = (this.$wire.input || '').trim();
|
||
if (this.tryConsumeConfirmation(text)) {
|
||
this.$wire.set('input', '');
|
||
return;
|
||
}
|
||
this.$wire.send();
|
||
}
|
||
},
|
||
resz(el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||
},
|
||
async toggleMic() { this.micActive ? this.stopMic() : await this.startMic(); },
|
||
async startMic() {
|
||
// 1) Fox sofort still: laufenden TTS abbrechen, sonst nimmt das Mic Fox selbst auf
|
||
if (this._sayAudio) { try { this._sayAudio.pause(); this._sayAudio.src = ''; } catch (_) {} this._sayAudio = null; }
|
||
if (this._sayAbort) { try { this._sayAbort.abort(); } catch (_) {} }
|
||
this._sayGen = (this._sayGen || 0) + 1;
|
||
this.isSpeaking = false;
|
||
// 2) Bootstrap-Audio (LLM-Antwort) auch killen
|
||
try { window.__foxAudio?.pause(); } catch (_) {}
|
||
// 3) Erst NACH kurzer Pause Mic starten - sonst hört Mic letzte 200ms TTS
|
||
await new Promise(r => setTimeout(r, 250));
|
||
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true,
|
||
},
|
||
});
|
||
this.micChunks = [];
|
||
this.mediaRecorder = new MediaRecorder(stream);
|
||
this.mediaRecorder.ondataavailable = (e) => this.micChunks.push(e.data);
|
||
this.mediaRecorder.onstop = () => this.uploadMic();
|
||
this.mediaRecorder.start();
|
||
this.micActive = true;
|
||
this.notify('🎤 Aufnahme läuft - klick zum Stoppen');
|
||
} catch (err) {
|
||
console.warn('Mikrofon nicht verfügbar:', err);
|
||
this.notify('Mikrofon nicht verfügbar: ' + err.message);
|
||
}
|
||
},
|
||
stopMic() {
|
||
if (!this.mediaRecorder) return;
|
||
this.mediaRecorder.stop();
|
||
this.mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||
this.mediaRecorder = null;
|
||
this.micActive = false;
|
||
},
|
||
async uploadMic() {
|
||
if (!this.micChunks.length) {
|
||
this.notify('Keine Aufnahme erfasst.');
|
||
return;
|
||
}
|
||
const blob = new Blob(this.micChunks, { type: 'audio/webm' });
|
||
if (blob.size < 1000) {
|
||
this.notify('Aufnahme zu kurz - länger drücken.');
|
||
return;
|
||
}
|
||
const fd = new FormData();
|
||
fd.append('audio', blob, 'fox-input.webm');
|
||
this.notify('Transkribiere …');
|
||
this.say(this.pick([
|
||
'Moment, ich höre.',
|
||
'Sekunde, verstehe das.',
|
||
'Hör mal kurz...',
|
||
]));
|
||
try {
|
||
const res = await fetch('/fox/voice', {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
|
||
body: fd,
|
||
});
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try {
|
||
const j = await res.json();
|
||
detail = j.detail || j.error || JSON.stringify(j).slice(0, 120);
|
||
} catch (_) {
|
||
detail = await res.text().catch(() => '');
|
||
}
|
||
console.warn('[Fox HUD] /fox/voice HTTP', res.status, detail);
|
||
this.notify(`Transkription HTTP ${res.status}: ${detail.slice(0, 80)}`);
|
||
this.say('Ich konnte dich nicht verstehen. Probier nochmal.');
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (!data?.text) {
|
||
console.warn('[Fox HUD] /fox/voice empty', data);
|
||
this.notify('Transkription leer');
|
||
this.say('Ich hab nichts gehört. Sprich lauter.');
|
||
return;
|
||
}
|
||
this.notify(`🎙 "${data.text}" — sende in 1.5s. Tippen bricht ab.`);
|
||
this.$wire.set('input', data.text);
|
||
// 1.5s warten - wenn User in dem Fenster tippt (input ändert sich), abbrechen
|
||
this._micConfirmTimer = setTimeout(async () => {
|
||
if ((this.$wire.input || '').trim() === data.text.trim()) {
|
||
await this.$nextTick();
|
||
this.$wire.send();
|
||
} else {
|
||
this.notify('Send abgebrochen, ändere und drück Enter.');
|
||
}
|
||
this._micConfirmTimer = null;
|
||
}, 1500);
|
||
} catch (err) {
|
||
console.warn('[Fox HUD] Voice upload error:', err);
|
||
this.notify('Upload fehlgeschlagen: ' + err.message);
|
||
this.say('Hat nicht geklappt. Probier nochmal.');
|
||
}
|
||
},
|
||
}));
|
||
});
|