PageSourceSearch

https://wordsmith.org/langitude/langitude.js

js wordsmith.org collected 2026-09-24 08:49:13 UTC 27,471 bytes, 707 lines download raw bytes

1// --- 0. CONFIGURATION ---
2const DISTANCE_LOCK = 15; 
3const MAX_GUESSES = 5; 
4const ENABLE_MERCY_ARROW = true; // Toggle the directional arrow during live radar
5
6// --- PATH LINE STYLES ---
7const PATH_STYLES = {
8    dashed: { color: '#d9534f', weight: 3, opacity: 0.8, dashArray: '8, 8' },
9    dotted: { color: '#00e5ff', weight: 4, opacity: 0.8, dashArray: '1, 8', lineCap: 'round', lineJoin: 'round' },
10    solid:  { color: '#d9534f', weight: 2, opacity: 0.5 }
11};
12const CURRENT_PATH_STYLE = 'dashed'; // Change this string to swap the style!
13
14// --- TILE PROVIDERS DICTIONARY ---
15const MAP_STYLES = {
16    "satellite": {
17        base: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
18        label: "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}"
19    },
20    "topo": { // Esri World Topo (Light, clean, topographical)
21        base: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
22        label: null // Labels are built-in
23    },
24    "osm": { // Standard OpenStreetMap
25        base: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
26        label: null // Labels are built-in
27    },
28    "light": { // Carto Light (Clean, minimalist)
29        base: "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",
30        label: "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}"
31    },
32    "dark": {
33        base: "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png",
34        label: "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}"
35    }
36};
37
38// --- 1. CORE STATE VARIABLES ---
39let gameWon = false, hintsUsed = 0, hintsTotal = 0, secondsElapsed = 0, timerInterval;
40let guessesMade = 0, lastMissDistanceMiles = null, worldData = null;
41let wordMapDatabase = [], todaysGame, todaysIndex, todayStr;
42let unit, timerEnabled, startLoc, mapStyle, hardModeEnabled, audioEnabled;
43let timerStarted = false, radarActive = false;
44let map, currentBaseLayer = null, currentLabelLayer = null;
45let audioCtx = null;
46
47// --- STATE VARS FOR GUESS PATH ---
48let guessPathCoords = [];
49let guessPathLayer = null;
50
51// --- DYNAMIC LAYOUT ENGINE ---
52function updateLayout() {
53    const hud = document.getElementById('hud-top');
54    const crosshair = document.getElementById('crosshair');
55    
56    if (!hud || hud.style.display === 'none') {
57        crosshair.style.top = '50%';
58        crosshair.style.left = '50%';
59        return;
60    }
61
62    const hudRect = hud.getBoundingClientRect();
63    
64    if (window.innerHeight <= 600 && window.innerWidth > window.innerHeight) {
65        crosshair.style.top = '50%';
66        crosshair.style.left = `${hudRect.left / 2}px`;
67    } else {
68        const spaceBelow = window.innerHeight - hudRect.bottom;
69        crosshair.style.top = `${hudRect.bottom + (spaceBelow / 2)}px`;
70        crosshair.style.left = '50%';
71    }
72}
73window.addEventListener('resize', updateLayout);
74
75// --- AUDIO ENGINE ---
76function initAudio() {
77    if (!audioCtx) {
78        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
79    }
80    if (audioCtx.state === 'suspended') audioCtx.resume();
81}
82
83function playSound(type) {
84    if (!audioEnabled) return;
85    initAudio();
86    
87    if (type === 'guess') {
88        const osc = audioCtx.createOscillator();
89        const gain = audioCtx.createGain();
90        osc.connect(gain);
91        gain.connect(audioCtx.destination);
92        osc.type = 'sine';
93        osc.frequency.setValueAtTime(600, audioCtx.currentTime);
94        osc.frequency.exponentialRampToValueAtTime(300, audioCtx.currentTime + 0.1);
95        gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
96        gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
97        osc.start();
98        osc.stop(audioCtx.currentTime + 0.15);
99    } else if (type === 'win') {
100        [523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => {
101            setTimeout(() => {
102                const osc = audioCtx.createOscillator();
103                const gain = audioCtx.createGain();
104                osc.connect(gain);
105                gain.connect(audioCtx.destination);
106                osc.type = 'sine';
107                osc.frequency.value = freq;
108                gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
109                gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4);
110                osc.start();
111                osc.stop(audioCtx.currentTime + 0.5);
112            }, i * 80);
113        });
114    }
115}
116
117// --- 2. INITIALIZATION & ROUTING ---
118window.onload = async function() {
119    try {
120        const response = await fetch(`langitude_data.json?v=${new Date().getTime()}`);
121        if (!response.ok) throw new Error("File not found");
122        wordMapDatabase = await response.json();
123    } catch (error) {
124        showError("Game data could not be loaded. Please ensure langitude_data.json 
124is on the server.");
125        return;
126    }
127
128    const urlParams = new URLSearchParams(window.location.search);
129    const requestedDate = urlParams.get('date');
130
131    const now = new Date();
132    const estString = now.toLocaleString("en-US", { timeZone: "America/New_York" });
133    const estDate = new Date(estString);
134    todayStr = `${estDate.getFullYear()}-${String(estDate.getMonth() + 1).padStart(2, '0')}-${String(estDate.getDate()).padStart(2, '0')}`;
135
136    // --- PREVENT TIME TRAVEL ---
137    if (requestedDate && requestedDate > todayStr) {
138        showError(`Nice try, time traveler! The puzzle for ${requestedDate} isn't available yet. Play today's game or check the archives.`);
139        return; 
140    }
141
142    const targetDateStr = requestedDate ? requestedDate : todayStr;
143
144    todaysIndex = wordMapDatabase.findIndex(game => game.date === targetDateStr);
145    
146    if (todaysIndex !== -1) {
147        todaysGame = wordMapDatabase[todaysIndex];
148    } else {
149        showError(`The puzzle for ${targetDateStr} is not available yet. Come back later or play the archives!`);
150        return;
151    }
152
153    unit = localStorage.getItem('wordmap_unit') || 'mi';
154    timerEnabled = localStorage.getItem('wordmap_timer') === 'true';
155    startLoc = localStorage.getItem('wordmap_startloc') || 'seattle';
156    mapStyle = localStorage.getItem('wordmap_style') || 'satellite';
157    hardModeEnabled = localStorage.getItem('wordmap_hardmode') === 'true';
158    audioEnabled = localStorage.getItem('wordmap_audio') !== 'false';
159
160    document.getElementById('game-word').innerText = todaysGame.word;
161    document.getElementById('game-definition').innerHTML = todaysGame.definition; 
162    document.getElementById('modal-location').innerText = todaysGame.locationName;
163    document.getElementById('modal-etymology').innerHTML = todaysGame.etymology; 
164    
165    document.getElementById('unit-select').value = unit;
166    document.getElementById('timer-toggle').checked = timerEnabled;
167    document.getElementById('start-loc-select').value = startLoc;
168    
169    // Add new styles to dropdown dynamically 
170    const styleSelect = document.getElementById('map-style-select');
171    if (styleSelect.options.length <= 2) {
172        styleSelect.innerHTML = `
173            <option value="satellite">Satellite</option>
174            <option value="topo">Topographic</option>
175            <option value="osm">Standard Map</option>
176            <option value="light">Light Minimalist</option>
177            <option value="dark">Dark Minimalist</option>
178        `;
179    }
180    styleSelect.value = mapStyle;
181    
182    document.getElementById('hardmode-toggle').checked = hardModeEnabled;
183    document.getElementById('audio-toggle').checked = audioEnabled;
184
185    hintsTotal = todaysGame.hints ? todaysGame.hints.length : 0;
186    if (hintsTotal > 0) {
187        document.getElementById('hint-btn').style.display = 'inline-block';
188        document.getElementById('hint-btn').innerText = `💡 Get a hint (${hintsTotal} left)`;
189    }
190
191    initMaps();
192};
193
194function showError(message) {
195    document.getElementById('hud-top').style.display = 'none';
196    document.getElementById('instructions-modal').style.display = 'none';
197    document.getElementById('error-screen').style.display = 'block';
198    document.getElementById('error-message').innerText = message;
199}
200
201// --- 3. MAPS & STARTING LOCATION ---
202
203// Records the map's starting location before any guesses are made
204function recordStartLocation() {
205    if (guessesMade === 0) {
206        const center = map.getCenter().wrap();
207        guessPathCoords = [[center.lat, center.lng]];
208    }
209}
210
211function setMapStyleLayers() {
212    if (currentBaseLayer) map.removeLayer(currentBaseLayer);
213    if (currentLabelLayer) map.removeLayer(currentLabelLayer);
214
215    const styleData = MAP_STYLES[mapStyle] || MAP_STYLES['satellite'];
216
217    currentBaseLayer = L.tileLayer(styleData.base, { maxZoom: 13 }).addTo(map);
218
219    if (styleData.label && !hardModeEnabled) {
220        currentLabelLayer = L.tileLayer(styleData.label, { maxZoom: 13 }).addTo(map);
221    }
222}
223
224function initMaps() {
225    map = L.map('map2d', { 
226        zoomControl: false,
227        minZoom: 2,
228        worldCopyJump: true,
229        attributionControl: false,
230        maxBounds: [[-90, -Infinity], [90, Infinity]],
231        maxBoundsViscosity: 1.0
232    });
233    
234    setMapStyleLayers();
235
236    if (startLoc === 'random') {
237        let rLat, rLng, distToTarget;
238        do {
239            rLat = (Math.random() - 0.5) * 140;
240            rLng = (Math.random() - 0.5) * 360;
241            distToTarget = turf.distance(turf.point([rLng, rLat]), turf.point([todaysGame.targetLng, todaysGame.targetLat]), {units: 'miles'});
242        } while (distToTarget < 1000);
243        map.setView([rLat, rLng], 3);
244        recordStartLocation();
245    } else if (startLoc === 'user' && navigator.geolocation) {
246        navigator.geolocation.getCurrentPosition(
247            (pos) => { map.setView([pos.coords.latitude, pos.coords.longitude], 4); recordStartLocation(); },
248            () => { map.setView([47.6062, -122.3321], 3); recordStartLocation(); }
249        );
250    } else {
251        map.setView([47.6062, -122.3321], 3);
252        recordStartLocation();
253    }
254
255    fetch('geo.json')
256        .then(res => {
257            if (!res.ok) throw new Error("File not found");
258            return res.json();
259        })
260        .then(data => { 
261            worldData = data;
262            setupEventListeners();
263        })
264        .catch((err) => {
265            console.warn("geo.json failed to load. Falling back to point-to-point.");
266            setupEventListeners();
267        });
268}
269
270function getCrosshairLatLng() {
271    const crosshair = document.getElementById('crosshair');
272    const rect = crosshair.getBoundingClientRect();
273    const x = rect.left + (rect.width / 2);
274    const y = rect.top + (rect.height / 2);
275    return map.containerPointToLatLng([x, y]).wrap();
276}
277
278// --- 4. GAMEPLAY ---
279
280// Draws the breadcrumb line connecting the guesses
281function updateGuessPath(latlng) {
282    guessPathCoords.push([latlng.lat, latlng.lng]);
283    
284    if (guessPathCoords.length < 2) return;
285    
286    if (guessPathLayer) {
287        map.removeLayer(guessPathLayer);
288    }
289    
290    guessPathLayer = L.polyline(guessPathCoords, PATH_STYLES[CURRENT_PATH_STYLE]).addTo(map);
291}
292
293function initiateTimer() {
294    if (timerEnabled && !timerStarted && !gameWon) {
295        timerStarted = true;
296        document.getElementById('timer-display').style.display = 'inline-block';
297        timerInterval = setInterval(() => {
298            secondsElapsed++;
299            document.getElementById('timer-display').innerText = `⏱️ ${Math.floor(secondsElapsed / 60)}:${(secondsElapsed % 60).toString().padStart(2, '0')}`;
300        }, 1000);
301    }
302}
303
304function setupEventListeners() {
305    map.once('movestart', initiateTimer);
306
307    document.getElementById('guess-btn').addEventListener('click', () => {
308        initiateTimer(); 
309        evaluateGuess(getCrosshairLatLng()); 
310    });
311
312    map.on('move', function() {
313        if (gameWon || !radarActive) return;
314        const targetPoint = getCrosshairLatLng(); 
315        const distMiles = turf.distance(turf.point([targetPoint.lng, targetPoint.lat]), turf.point([todaysGame.targetLng, todaysGame.targetLat]), {units: 'miles'});
316        const displayDist = Math.round(unit === 'mi' ? distMiles : distMiles * 1.60934).toLocaleString();
317        
318        let arrow = "";
319        if (ENABLE_MERCY_ARROW) {
320            const bearing = turf.bearing(turf.point([targetPoint.lng, targetPoint.lat]), turf.point([todaysGame.targetLng, todaysGame.targetLat]));
321            const arrows = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"];
322            const normalizedBearing = (bearing + 360) % 360;
323            const index = Math.round(normalizedBearing / 45) % 8;
324            arrow = " " + arrows[index];
325        }
326        
327        document.getElementById('last-guess-display').innerText = `You're ${displayDist} ${unit} away.${arrow}`;
328        document.getElementById('last-guess-display').style.color = "#00e5ff";
329    });
330
331    if (!localStorage.getItem('wordmap_onboarded')) {
332        document.getElementById('instructions-modal').style.display = 'block';
333    } else {
334        document.getElementById('hud-top').style.display = 'block'; 
335        if (timerEnabled) document.getElementById('timer-display').style.display = 'inline-block';
336        updateLayout(); 
337    }
338}
339
340function evaluateGuess(latlng) {
341    if (gameWon) return;
342    guessesMade++;
343    
344    updateGuessPath(latlng); 
345    
346    const userPoint = turf.point([latlng.lng, latlng.lat]);
347    let isCorrect = false;
348    let finalDistMiles = turf.distance(userPoint, turf.point([todaysGame.targetLng, todaysGame.targetLat]), {units: 'miles'});
349
350    if (todaysGame.targetType === "country" && worldData) {
351        const targetFeature = worldData.features.find(f => 
352            f.id === todaysGame.targetID || 
353            (f.properties && (
354                f.properties.ISO_A3 === todaysGame.targetID || 
355                f.properties.iso_a3 === todaysGame.targetID || 
356                f.properties.ADM0_A3 === todaysGame.targetID
357            ))
358        );
359
360        if (targetFeature && turf.booleanPointInPolygon(userPoint, targetFeature)) {
361            isCorrect = true;
362        }
363    } 
364    
365    const allowedRadius = todaysGame.targetType === "country" ? Math.max(DISTANCE_LOCK, 150) : DISTANCE_LOCK;
366
367    if (finalDistMiles <= allowedRadius) isCorrect = true;
368
369    if (isCorrect) {
370        playSound('win');
371        const isTrueWin = !radarActive;
372        processStats(isTrueWin);
373        triggerWinSequence(latlng);
374    } else {
375        playSound('guess');
376        lastMissDistanceMiles = finalDistMiles;
377        
378        if (guessesMade >= MAX_GUESSES && !radarActive) {
379            radarActive = true;
380            map.fire('move'); 
381        } else {
382            updateMissDisplay();
383        }
384    }
385}
386
387function updateMissDisplay() {
388    if (lastMissDistanceMiles === null) return;
389    const displayDist = Math.round(unit === 'mi' ? lastMissDistanceMiles : lastMissDistanceMiles * 1.60934).toLocaleStr
389ing();
390    
391    const displayEl = document.getElementById('last-guess-display');
392    
393    if (guessesMade <= MAX_GUESSES) {
394        displayEl.innerText = `Guess ${guessesMade} of ${MAX_GUESSES}: You're ${displayDist} ${unit} away.`;
395    } else {
396        displayEl.innerText = `You're ${displayDist} ${unit} away.`;
397    }
398    
399    displayEl.style.backgroundColor = "rgba(255, 68, 68, 0.4)";
400    displayEl.style.color = "#ffffff";
401    
402    setTimeout(() => { 
403        if (!gameWon) {
404            displayEl.style.backgroundColor = "transparent";
405            displayEl.style.color = "#ffaa00"; 
406        }
407    }, 800);
408}
409
410function processStats(isTrueWin) {
411    const urlParams = new URLSearchParams(window.location.search);
412    if (urlParams.get('date') && urlParams.get('date') !== todayStr) return; 
413
414    let stats = JSON.parse(localStorage.getItem('wordmap_stats')) || { played: 0, wins: 0, currentStreak: 0, maxStreak: 0, lastDate: null };
415
416    if (stats.lastDate === todayStr) return populateStatsUI(stats);
417
418    stats.played++;
419    stats.lastDate = todayStr;
420
421    if (isTrueWin) {
422        stats.wins++;
423        stats.currentStreak++;
424        if (stats.currentStreak > stats.maxStreak) stats.maxStreak = stats.currentStreak;
425    } else {
426        stats.currentStreak = 0;
427    }
428
429    localStorage.setItem('wordmap_stats', JSON.stringify(stats));
430    populateStatsUI(stats);
431}
432
433function populateStatsUI(stats) {
434    const winPct = stats.played > 0 ? Math.round((stats.wins / stats.played) * 100) : 0;
435    document.getElementById('stat-played').innerText = stats.played;
436    document.getElementById('stat-winpct').innerText = `${winPct}%`;
437    document.getElementById('stat-streak').innerText = stats.currentStreak;
438    document.getElementById('stat-maxstreak').innerText = stats.maxStreak;
439}
440
441function triggerWinSequence(finalGuessLatLng) {
442    gameWon = true; 
443    clearInterval(timerInterval);
444    
445    const displayEl = document.getElementById('last-guess-display');
446    displayEl.style.backgroundColor = "transparent";
447    displayEl.innerText = 'LOCKED ON!';
448    displayEl.style.color = '#00FF00';
449    
450    map.dragging.disable(); 
451    map.scrollWheelZoom.disable();
452
453    const flyTarget = [todaysGame.targetLat, todaysGame.targetLng];
454    
455    const startPt = turf.point([finalGuessLatLng.lng, finalGuessLatLng.lat]);
456    const endPt = turf.point([todaysGame.targetLng, todaysGame.targetLat]);
457    const arc = turf.greatCircle(startPt, endPt);
458    
459    const distToTarget = turf.distance(startPt, endPt, {units: 'miles'});
460    if (distToTarget > 1) {
461        L.geoJSON(arc, { 
462            style: { color: '#00FF00', weight: 3, dashArray: '6, 8' } 
463        }).addTo(map);
464    }
465
466    if (todaysGame.targetType === "country" && worldData) {
467        const feat = worldData.features.find(f => 
468            f.id === todaysGame.targetID || 
469            (f.properties && (f.properties.ISO_A3 === todaysGame.targetID || f.properties.iso_a3 === todaysGame.targetID || f.properties.ADM0_A3 === todaysGame.targetID))
470        );
471        if (feat) L.geoJSON(feat, { style: { color: "#00FF00", weight: 3, fillOpacity: 0.3 } }).addTo(map);
472    } else {
473        L.circle(flyTarget, { color: '#00FF00', radius: 20000 }).addTo(map);
474    }
475
476    map.flyTo(flyTarget, 5, { duration: 2.5 });
477    document.getElementById('crosshair').style.opacity = '0';
478
479    setTimeout(() => {
480        if (document.getElementById('stat-played').innerText === "0") {
481            const stats = JSON.parse(localStorage.getItem('wordmap_stats')) || { played: 0, wins: 0, currentStreak: 0, maxStreak: 0 };
482            populateStatsUI(stats);
483        }
484        document.getElementById('success-modal').style.display = 'block'; 
485    }, 2500);
486}
487
488// --- 5. UI, MENUS & SETTINGS ---
489window.toggleFullscreen = function() {
490    if (!document.fullscreenElement) {
491        document.documentElement.requestFullscreen().catch(err => {
492            console.log(`Error attempting to enable fullscreen: ${err.message}`);
493        });
494    } else {
495        if (document.exitFullscreen) {
496            document.exitFullscreen();
497        }
498    }
499};
500
501window.closeSuccessModal = function() {
502    document.getElementById('success-modal').style.display = 'none';
503    map.dragging.enable();
504    map.scrollWheelZoom.enable();
505    
506    document.getElementById('aim-submit-row').style.display = 'none';
507    document.getElementById('last-guess-display').style.display = 'none';
508    
509    updateLayout();
510};
511
512function toggleSidebar() {
513    const sb = document.getElementById('sidebar');
514    sb.style.display = sb.style.display === 'none' ? 'block' : 'none';
515}
516
517window.openArchive = function(gameType) {
518    toggleSidebar();
519    
520    document.getElementById('success-modal').style.display = 'none';
521    document.getElementById('settings-modal').style.display = 'none';
522
523    if (gameType === 'langitude') {
524        const listDiv = document.getElementById('archive-list');
525        listDiv.innerHTML = ''; 
526        
527        const pastGames = wordMapDatabase
528            .filter(game => game.date <= todayStr)
529            .sort((a, b) => b.date.localeCompare(a.date));
530        
531        const grouped = {};
532        pastGames.forEach(game => {
533            const [y, m, d] = game.date.split('-');
534            const monthKey = `${y}-${m}`;
535            if (!grouped[monthKey]) grouped[monthKey] = [];
536            grouped[monthKey].push(game);
537        });
538
539        for (const [monthKey, games] of Object.entries(grouped)) {
540            const [y, m] = monthKey.split('-');
541            const yearNum = parseInt(y);
542            const monthNum = parseInt(m) - 1;
543            const daysInMonth = new Date(yearNum, monthNum + 1, 0).getDate();
544            const firstDayOfWeek = new Date(yearNum, monthNum, 1).getDay(); 
545
546            const monthName = new Date(yearNum, monthNum).toLocaleString('default', { month: 'long', year: 'numeric' });
547            
548            const monthTitle = document.createElement('h3');
549            monthTitle.innerText = monthName;
550            monthTitle.style.color = '#4CAF50';
551            monthTitle.style.marginTop = '15px';
552            monthTitle.style.marginBottom = '5px';
553            monthTitle.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
554            listDiv.appendChild(monthTitle);
555
556            const grid = document.createElement('div');
557            grid.className = 'calendar-grid';
558
559            const daysOfWeek = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
560            daysOfWeek.forEach(day => {
561                const el = document.createElement('div');
562                el.innerText = day;
563                el.className = 'calendar-header';
564                grid.appendChild(el);
565            });
566
567            for (let i = 0; i < firstDayOfWeek; i++) {
568                grid.appendChild(document.createElement('div'));
569            }
570
571            const gamesByDay = {};
572            games.forEach(g => {
573                const d = parseInt(g.date.split('-')[2]);
574                gamesByDay[d] = g;
575            });
576
577            for (let day = 1; day <= daysInMonth; day++) {
578                const game = gamesByDay[day];
579
580                if (game && game.date <= todayStr) {
581                    const link = document.createElement('a');
582                    link.href = `?date=${game.date}`;
583                    link.innerText = day;
584                    link.title = game.word;
585                    link.className = 'calendar-day active-day';
586                    if (game.date === todayStr) link.classList.add('today-day');
587                    grid.appendChild(link);
588                } else {
589                    const emptyDay = document.createElement('div');
590                    emptyDay.innerText = day;
591                    emptyDay.className = 'calendar-day disabled-day';
592                    grid.appendChild(emptyDay);
593                }
594            }
595            listDiv.appendChild(grid);
596        }
597        document.getElementById('archive-modal').style.display = 'block';
598    }
599};
600
601document.getElementById('hint-btn').addEventListener('click', () => {
602    initiateTimer(); 
603    if (hintsUsed < hintsTotal) {
604        document.getElementById('hint-display').style.display = 'block';
605        document.getElementById('hint-display').innerHTML = todaysGame.hints[hintsUsed++];
606        
607        const hintsLeft = hintsTotal - hintsUsed;
608        const btn = document.getElementById('hint-btn');
609        if (hintsLeft > 0) {
610            btn.innerText = `💡 Get a hint (${hintsLeft} left)`;
611        } else {
612            btn.innerText = `💡 No hints left`;
613            btn.disabled = true;
614            btn.classList.add('disabled-btn');
615        }
616        
617        setTimeout(() => {
618            const hud = document.getElementById('hud-top');
619            hud.scrollTo({ top: hud.scrollHeight, behavior: 'smooth' });
620            updateLayout(); 
621        }, 50);
622    }
623});
624
625document.getElementById('gear-btn').addEventListener('click', () => { document.getElementById('settings-modal').style.display = 'block'; });
626
627window.closeSettings = function() {
628    unit = document.getElementById('unit-select').value;
629    startLoc = document.getElementById('start-loc-select').value;
630    
631    const newMapStyle = document.getElementById('map-style-select').value;
632    const newHardMode = document.getElementById('hardmode-toggle').checked;
633    audioEnabled = document.getElementById('audio-toggle').checked;
634
635    localStorage.setItem('wordmap_unit', unit);
636    localStorage.setItem('wordmap_startloc', startLoc);
637    localStorage.setItem('wordmap_hardmode', newHardMode);
638    localStorage.setItem('wordmap_audio', audioEnabled);
639    
640    if (newMapStyle !== mapStyle || newHardMode !== hardModeEnabled) {
641        mapStyle = newMapStyle;
642        hardModeEnabled = newHardMode;
643        localStorage.setItem('wordmap_style', mapStyle);
644        setMapStyleLayers(); 
645    }
646    
647    if (!gameWon && lastMissDistanceMiles !== null) {
648        updateMissDisplay();
649        if (radarActive) map.fire('move');
650    }
651    
652    const prevTimer = timerEnabled;
653    timerEnabled = document.getElementById('timer-toggle').checked;
654    localStorage.setItem('wordmap_timer', timerEnabled);
655    
656    if (!timerEnabled) { 
657        clearInterval(timerInterval); 
658        timerInterval = null; 
659        timerStarted = false;
660        document.getElementById('timer-display').style.display = 'none'; 
661    } else if (timerEnabled && !prevTimer && !timerStarted) {
662        document.getElementById('timer-display').style.display = 'inline-block';
663        document.getElementById('timer-display').innerText = "⏱️ 0:00";
664    }
665
666    document.getElementById('settings-modal').style.display = 'none';
667    
668    if (!gameWon && !timerStarted && guessesMade === 0) {
669        if (startLoc === 'random') {
670            let rLat, rLng, distToTarget;
671            do {
672                rLat = (Math.random() - 0.5) * 140;
673                rLng = (Math.random() - 0.5) * 360;
674                distToTarget = turf.distance(turf.point([rLng, rLat]), turf.point([todaysGame.targetLng, todaysGame.targetLat]), {units: 'miles'});
675            } while (distToTarget < 1000);
676            map.setView([rLat, rLng], 3);
677            recordStartLocation();
678        }
679        else if (startLoc === 'seattle') {
680            map.setView([47.6062, -122.3321], 3);
681            recordStartLocation();
682        }
683        else if (startLoc === 'user' && navigator.geolocation) {
684            navigator.geolocation.getCurrentPosition((pos) => { 
685                map.setView([pos.coords.latitude, pos.coords.longitude], 4); 
686                recordStartLocation();
687            });
688        }
689    }
690};
691
692window.closeInstructions = function() {
693    document.getElementById('instructions-modal').style.display = 'none';
694    document.getElementById('hud-top').style.display = 'block'; 
695    localStorage.setItem('wordmap_onboarded', 'true');
696    updateLayout(); 
697};
698
699window.copyShareText = function() {
700    const hintStr = hintsTotal > 0 ? `\n💡 Hints: ${hintsUsed}/${hintsTotal}` : '';
701    const guessRatio = guessesMade <= MAX_GUESSES && !radarActive ? `${guessesMade}/${MAX_GUESSES}` : `X/${MAX_GUESSES}`;
702    
703    const text = `Langitude ${todaysGame.date}\nWord: ${todaysGame.word}\n🎯 Guesses: ${guessRatio}${hintStr}\nwordsmith.org/langitude`;
704    
705    navigator.clipboard.writeText(text);
706    document.getElementById('share-btn').innerText = 'Copied!';
707};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.