Files

344 lines
12 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const $ = s => document.querySelector(s), $$ = s => document.querySelectorAll(s);
// Dark/light theme.
const darkQuery = matchMedia('(prefers-color-scheme: dark)');
const themeListeners = [];
const isDark = () =>
(document.body.dataset.theme || (darkQuery.matches ? 'dark' : 'light')) === 'dark';
const notifyTheme = () => themeListeners.forEach(fn => fn(isDark()));
$('#theme-toggle')?.addEventListener('click', () => {
document.body.dataset.theme = isDark() ? 'light' : 'dark';
try { localStorage.setItem('ooru-theme', document.body.dataset.theme); } catch {}
notifyTheme();
});
// Fall back to system if there's no saved pref.
darkQuery.addEventListener('change', () => { if (!document.body.dataset.theme) notifyTheme(); });
const items = $$('[data-space]');
if (!items.length) {
return;
}
const elSearch = $('#search'), elCount = $('#count');
const elChk = $$('[data-filter]');
const elMap = $('[data-map]'), elShowMap = $('#show-map'), elToggles = $$('[data-toggle]');
const elGroups = $$('.filter-group');
const isMobile = matchMedia('(max-width: 750px)').matches;
let map, isMapLoaded = false, selItem = null, mapTimer = 0;
const markers = [], markerItems = new Map();
// Build city+state mappings for filtering.
const cityToState = {}, stateToCities = {};
items.forEach(i => {
i._text = i.textContent.toLowerCase();
i._aliases = (i.dataset.cityAliases || '').toLowerCase();
const { city, state } = i.dataset;
if (city && state) {
cityToState[city] = state;
(stateToCities[state] ||= new Set()).add(city);
}
});
// Checkbox groups for bulk toggling.
const groupBoxes = {};
elToggles.forEach(t => {
if (t.dataset.toggle) groupBoxes[t.dataset.toggle] = $$(`input[name="${t.dataset.toggle}"]`);
});
// Map logic.
function loadMap() {
if (isMapLoaded) return;
// Lazy-load only once, when the map is first shown.
isMapLoaded = true;
document.head.append(
Object.assign(document.createElement('link'), { rel: 'stylesheet', href: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' })
);
const s = Object.assign(document.createElement('script'), { src: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' });
s.onload = initMap;
document.head.append(s);
}
const defStyle = { radius: 7, color: '#fff', weight: 1.5, fillColor: 'blue', fillOpacity: 0.85 };
const hiStyle = { radius: 10, color: '#fff', weight: 2, fillColor: 'blue', fillOpacity: 1 };
function styleMarker(m, style, open) {
m.setStyle(style); m.setRadius(style.radius);
if (open) { m.bringToFront(); m.openPopup(); } else m.closePopup();
}
// Initialize map with LeafletJS. Dim non-India areas.
function initMap() {
if (!$('#map') || typeof L === 'undefined') return;
map = L.map('map', { maxBounds: [[5, 67], [38, 98]], maxBoundsViscosity: 1, minZoom: 4 }).setView([22, 79], 5);
map.on('moveend', onMapMove);
// Make the country border brighter in the dark mode.
const dimStyle = () => isDark()
? { fillColor: '#000', fillOpacity: 0.6, stroke: true, color: '#7a8390', weight: 1, opacity: 0.8 }
: { fillColor: '#ddd', fillOpacity: 0.6, stroke: false };
// Swap CARTO basemap + labels + dim styling to match the active theme.
let baseLayer, labelLayer, dimLayer;
function applyMapTheme() {
const v = isDark() ? 'dark' : 'light';
baseLayer?.remove();
labelLayer?.remove();
baseLayer = L.tileLayer(`https://{s}.basemaps.cartocdn.com/${v}_nolabels/{z}/{x}/{y}{r}.png`, {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd'
}).addTo(map);
labelLayer = L.tileLayer(`https://{s}.basemaps.cartocdn.com/${v}_only_labels/{z}/{x}/{y}{r}.png`,
{ subdomains: 'abcd', pane: 'shadowPane' }).addTo(map);
dimLayer?.setStyle(dimStyle());
}
applyMapTheme();
themeListeners.push(applyMapTheme);
// Fetch India's boundaries and dim everything else outside it.
fetch('/india.geojson').then(r => r.json()).then(india => {
const world = [[-90, -180], [-90, 180], [90, 180], [90, -180]];
const polys = india.geometry.type === 'Polygon' ? [india.geometry.coordinates] : india.geometry.coordinates;
dimLayer = L.polygon([world, ...polys.map(p => p[0].map(c => [c[1], c[0]]))],
{ interactive: false, ...dimStyle() }).addTo(map);
}).catch(() => {});
items.forEach(item => {
const lat = parseFloat(item.dataset.lat), lng = parseFloat(item.dataset.lng);
if (isNaN(lat) || isNaN(lng)) return;
// Build the pin-popup card.
const q = [item.dataset.name, item.dataset.address, item.dataset.city].filter(Boolean).join(', ');
const popup = `<strong>${item.dataset.name}</strong>`
+ (item.dataset.address ? `<br><span class="popup-address">${item.dataset.address}</span>` : '')
+ `<br><a href="https://www.google.com/maps/search/${encodeURIComponent(q)}" target="_blank" rel="noopener">Search on Google Maps &rarr;</a>`;
const marker = L.circleMarker([lat, lng], defStyle).bindPopup(popup, { autoClose: false }).addTo(map);
markers.push({ marker, item });
markerItems.set(item, marker);
item.addEventListener('mouseenter', () => {
if (selItem === item) return;
styleMarker(marker, hiStyle, true);
});
item.addEventListener('mouseleave', () => {
if (selItem === item) return;
styleMarker(marker, defStyle, false);
});
item.addEventListener('click', () => {
if (selItem) {
selItem.setAttribute('aria-selected', 'false');
const prev = markerItems.get(selItem);
if (prev) {
styleMarker(prev, defStyle, false);
}
}
if (selItem === item) {
selItem = null;
return;
}
selItem = item;
item.setAttribute('aria-selected', 'true');
styleMarker(marker, hiStyle, true);
});
marker.on('mouseover', () => {
if (selItem !== item) styleMarker(marker, hiStyle, true);
});
marker.on('mouseout', () => {
if (selItem !== item) styleMarker(marker, defStyle, false);
});
marker.on('click', () => {
if (selItem) {
selItem.setAttribute('aria-selected', 'false');
const prev = markerItems.get(selItem);
if (prev) styleMarker(prev, defStyle, false);
}
if (selItem === item) {
selItem = null;
return;
}
selItem = item;
item.setAttribute('aria-selected', 'true');
styleMarker(marker, hiStyle, true);
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
});
updateMap();
}
function updateMap() {
if (!map || elMap?.style.display === 'none') return;
const bounds = [];
markers.forEach(({ marker, item }) => {
item.hidden ? marker.remove() : (marker.addTo(map), bounds.push(marker.getLatLng()));
});
if (!bounds.length) return;
map.off('moveend', onMapMove);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
map.once('moveend', () => map.on('moveend', onMapMove));
}
function onMapMove() {
if (!map) return;
const bounds = map.getBounds();
let n = 0;
items.forEach(item => {
if (!item._filterMatch) return;
const marker = markerItems.get(item);
if (marker) {
const inView = bounds.contains(marker.getLatLng());
item.hidden = !inView;
if (inView) n++;
} else {
n++;
}
});
elCount.textContent = n;
}
// Filtering logic.
function cascadeFilters(changed) {
const cb = groupBoxes.city, sb = groupBoxes.state;
if (!cb || !sb) return;
if (changed.name === 'city') {
const need = new Set();
cb.forEach(c => {
if (c.checked && cityToState[c.value]) {
need.add(cityToState[c.value]);
}
});
sb.forEach(c => { c.checked = need.has(c.value); });
} else if (changed.name === 'state') {
const need = new Set();
sb.forEach(c => {
if (c.checked) {
(stateToCities[c.value] || []).forEach(v => need.add(v));
}
});
cb.forEach(c => { c.checked = need.has(c.value); });
}
}
function updateToggleLabels() {
elToggles.forEach(t => {
const boxes = groupBoxes[t.dataset.toggle];
if (!boxes) return;
const action = Array.from(boxes).every(c => c.checked) ? 'Unselect all' : 'Select all';
t.textContent = action;
t.setAttribute('aria-label', t.dataset.group ? `${action} ${t.dataset.group}` : action);
});
}
function filter() {
const q = (elSearch?.value || '').toLowerCase();
const active = {};
elChk.forEach(cb => {
if (cb.checked) {
(active[cb.name] ||= []).push(cb.value);
}
});
let n = 0;
items.forEach(item => {
const show = (!q || item._text.includes(q) || item._aliases.includes(q)) && Object.entries(active).every(([k, v]) =>
k === 'category' ? v.some(x => item.dataset.categories.split(',').includes(x)) : v.includes(item.dataset[k])
);
item.hidden = !show;
item._filterMatch = show;
if (show) {
n++;
}
});
elCount.textContent = n;
clearTimeout(mapTimer);
mapTimer = setTimeout(updateMap, 500);
}
// Persist filters and map show/hide state.
const STORE = 'ooru-state';
function saveState() {
const off = [];
elChk.forEach(cb => { if (!cb.checked) off.push(cb.name + ':' + cb.value); });
const open = [];
elGroups.forEach(d => { if (d.open) open.push(d.dataset.group); });
localStorage.setItem(STORE, JSON.stringify({
q: elSearch?.value || '', off, open, map: elShowMap ? elShowMap.checked : true
}));
}
function loadState() {
try { return JSON.parse(localStorage.getItem(STORE)) || {}; } catch { return {}; }
}
const elMapToggle = $('#map-toggle'), elFilterToggle = $('#filter-toggle'), elFilterGroups = $('.filter-groups');
// Toggle map visibility.
function setMap(show) {
if (elShowMap) elShowMap.checked = show;
if (elMapToggle) {
elMapToggle.textContent = show ? 'Hide map' : 'Show map';
elMapToggle.setAttribute('aria-expanded', show ? 'true' : 'false');
}
if (elMap) elMap.style.display = show ? '' : 'none';
if (show) isMapLoaded ? (map?.invalidateSize(), updateMap()) : loadMap();
saveState();
}
// Bind control events.
elSearch?.addEventListener('input', () => { filter(); saveState(); });
elChk.forEach(cb => cb.addEventListener('change', e => {
cascadeFilters(e.target); updateToggleLabels(); filter(); saveState();
}));
elShowMap?.addEventListener('change', () => setMap(elShowMap.checked));
elMapToggle?.addEventListener('click', e => { e.preventDefault(); setMap(elMap?.style.display === 'none'); });
// Filter toggle on mobile view.
elFilterToggle?.addEventListener('click', e => {
e.preventDefault();
const open = elFilterGroups.classList.toggle('open');
elFilterToggle.textContent = open ? 'Hide filters' : 'Show filters';
elFilterToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Persist accordion open/closed state.
elGroups.forEach(d => d.addEventListener('toggle', saveState));
elToggles.forEach(t => t.addEventListener('click', e => {
e.preventDefault();
const boxes = groupBoxes[t.dataset.toggle];
if (!boxes) return;
const all = Array.from(boxes).every(c => c.checked);
boxes.forEach(c => c.checked = !all);
updateToggleLabels(); filter(); saveState();
}));
// Restore saved settings.
const saved = loadState();
if (elSearch) elSearch.value = saved.q || '';
const off = new Set(saved.off || []);
elChk.forEach(cb => cb.checked = !off.has(cb.name + ':' + cb.value));
// Restore accordion states/
if (Array.isArray(saved.open)) {
const openSet = new Set(saved.open);
elGroups.forEach(d => d.open = openSet.has(d.dataset.group));
}
if (isMobile && elShowMap) elShowMap.closest('label').style.display = 'none';
setMap(saved.map !== false);
updateToggleLabels(); filter();
});