344 lines
12 KiB
JavaScript
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: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>, © <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 →</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();
|
|
});
|