From 8de74d758394d44169826a965c8101c35c3ffec2 Mon Sep 17 00:00:00 2001 From: Quincy Morgan <2046746+quincylvania@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:05:49 -0500 Subject: [PATCH] Refactor JS to use modules --- index.html | 11 +- js/controlsController.js | 46 ++++ js/dataController.js | 68 ------ js/hashController.js | 53 ++++- js/index.js | 444 --------------------------------------- js/mapController.js | 173 +++++++++++++-- js/optionsData.js | 227 ++++++++++++++++++++ js/osmController.js | 79 +++++++ js/sidebarController.js | 101 ++++++--- js/stateController.js | 91 ++++++++ js/styleGenerator.js | 24 ++- js/utils.js | 46 ++-- 12 files changed, 748 insertions(+), 615 deletions(-) create mode 100644 js/controlsController.js delete mode 100644 js/dataController.js delete mode 100644 js/index.js create mode 100644 js/optionsData.js create mode 100644 js/osmController.js create mode 100644 js/stateController.js diff --git a/index.html b/index.html index e970be8..4691003 100644 --- a/index.html +++ b/index.html @@ -67,12 +67,9 @@

< - - - - - - - + + + + diff --git a/js/controlsController.js b/js/controlsController.js new file mode 100644 index 0000000..46b80dc --- /dev/null +++ b/js/controlsController.js @@ -0,0 +1,46 @@ +import { state } from "./stateController.js"; +import { lensOptionsByMode, lensStrings } from "./optionsData.js"; + +function updateLensControl() { + let html = ""; + let items = lensOptionsByMode[state.travelMode]; + + html += ''; + items.forEach(function(item) { + if (item.subitems) { + html += ''; + item.subitems.forEach(function(item) { + let label = item.label ? item.label : lensStrings[item].label; + html += ''; + }) + html += ''; + } + }); + let lensElement = document.getElementById("lens"); + lensElement.innerHTML = html; + lensElement.value = state.lens; +} + +window.addEventListener('load', function() { + + updateLensControl(); + + document.getElementById("travel-mode").addEventListener('change', function(e) { + state.setTravelMode(e.target.value); + }); + document.getElementById("lens").addEventListener('change', function(e) { + state.setLens(e.target.value); + }); + document.getElementById("clear-focus").addEventListener('click', function(e) { + e.preventDefault(); + state.focusEntity(); + }); + + state.addEventListener('travelModeChange', function() { + updateLensControl(); + document.getElementById("travel-mode").value = state.travelMode; + }); + state.addEventListener('lensChange', function() { + document.getElementById("lens").value = state.lens; + }); +}); diff --git a/js/dataController.js b/js/dataController.js deleted file mode 100644 index d817b6a..0000000 --- a/js/dataController.js +++ /dev/null @@ -1,68 +0,0 @@ -let osmEntityCache = {}; -let osmEntityMembershipCache = {}; -let osmChangesetCache = {}; - -function cacheEntities(elements, full) { - for (let i in elements) { - let element = elements[i]; - let type = element.type; - let id = element.id; - let key = type[0] + id; - - osmEntityCache.full = full; - - osmEntityCache[key] = element; - } -} - -async function fetchOsmEntity(type, id) { - let key = type[0] + id; - if (!osmEntityCache[key] || !osmEntityCache[key].full) { - let url = `https://api.openstreetmap.org/api/0.6/${type}/${id}`; - if (type !== 'node') { - url += '/full'; - } - url += '.json'; - let response = await fetch(url); - let json = await response.json(); - cacheEntities(json && json.elements || [], true); - } - return osmEntityCache[key]; -} - -async function fetchOsmEntityMemberships(type, id) { - let key = type[0] + id; - - if (!osmEntityMembershipCache[key]) { - let response = await fetch(`https://api.openstreetmap.org/api/0.6/${type}/${id}/relations.json`); - let json = await response.json(); - let rels = json && json.elements || []; - - osmEntityMembershipCache[key] = []; - rels.forEach(function(rel) { - rel.members.forEach(function(membership) { - if (membership.ref === id && membership.type === type) { - osmEntityMembershipCache[key].push({ - key: rel.type[0] + rel.id, - role: membership.role, - }); - } - }); - }); - - // response relations are fully defined entities so we can cache them for free - cacheEntities(rels, false); - } - - return osmEntityMembershipCache[key]; -} - -async function fetchOsmChangeset(id) { - if (!osmChangesetCache[id]) { - let url = `https://api.openstreetmap.org/api/0.6/changeset/${id}.json`; - let response = await fetch(url); - let json = await response.json(); - osmChangesetCache[id] = json && json.changeset; - } - return osmChangesetCache[id]; -} \ No newline at end of file diff --git a/js/hashController.js b/js/hashController.js index 19968ae..ba2d343 100644 --- a/js/hashController.js +++ b/js/hashController.js @@ -1,3 +1,5 @@ +import { state } from "./stateController.js"; + function setHashParameters(params) { let searchParams = new URLSearchParams(window.location.hash.slice(1)); for (let key in params) { @@ -45,10 +47,47 @@ function selectedEntityInfoFromHash() { return null; } -function updateForHash(skipMapUpdate) { - setTravelMode(hashValue("mode"), skipMapUpdate); - setLens(hashValue("lens"), skipMapUpdate); - selectEntity(selectedEntityInfoFromHash(), skipMapUpdate); - focusEntity(focusedEntityInfoFromHash(), skipMapUpdate); - hashValue("inspect") ? openSidebar() : closeSidebar(); -} \ No newline at end of file +function updateForHash() { + state.setInspectorOpen(hashValue("inspect")); + state.setTravelMode(hashValue("mode")); + state.setLens(hashValue("lens")); + state.selectEntity(selectedEntityInfoFromHash()); + state.focusEntity(focusedEntityInfoFromHash()); +} + +window.addEventListener('load', function() { + + updateForHash(); + + window.addEventListener("hashchange", function() { + updateForHash(); + }); + + state.addEventListener('inspectorOpenChange', function() { + setHashParameters({ inspect: state.inspectorOpen ? '1' : null }); + }); + state.addEventListener('lensChange', function() { + setHashParameters({ lens: state.lens === state.defaultLens ? null : state.lens }); + }); + state.addEventListener('travelModeChange', function() { + setHashParameters({ mode: state.travelMode === state.defaultTravelMode ? null : state.travelMode }); + }); + state.addEventListener('selectedEntityChange', function() { + let selectedEntityInfo = state.selectedEntityInfo; + let type = selectedEntityInfo?.type; + let entityId = selectedEntityInfo?.id; + setHashParameters({ + selected: selectedEntityInfo ? type + "/" + entityId : null + }); + }); + state.addEventListener('focusedEntityChange', function() { + let focusedEntityInfo = state.focusedEntityInfo; + let type = focusedEntityInfo?.type; + let entityId = focusedEntityInfo?.id; + setHashParameters({ + focus: focusedEntityInfo ? type + "/" + entityId : null + }); + }); + +}); + diff --git a/js/index.js b/js/index.js deleted file mode 100644 index 42e153f..0000000 --- a/js/index.js +++ /dev/null @@ -1,444 +0,0 @@ -let map; - -let lensStrings = { - access: { - label: "Access" - }, - covered: { - label: "Covered" - }, - dog: { - label: "Dog Access" - }, - incline: { - label: "Incline" - }, - lit: { - label: "Lit" - }, - maxspeed: { - label: "Speed Limit" - }, - name: { - label: "Name" - }, - oneway: { - label: "Oneway" - }, - operator: { - label: "Operator" - }, - sac_scale: { - label: "SAC Hiking Scale" - }, - smoothness: { - label: "Smoothness" - }, - surface: { - label: "Surface" - }, - trail_visibility: { - label: "Trail Visibility" - }, - width: { - label: "Width" - }, - fixme: { - label: "Fixme Requests" - }, - check_date: { - label: "Last Checked Date" - }, - OSM_TIMESTAMP: { - label: "Last Edited Date" - }, - intermittent: { - label: "Intermittent" - }, - open_water: { - label: "Open Water" - }, - rapids: { - label: "Rapids" - }, - tidal: { - label: "Tidal" - }, - hand_cart: { - label: "Hand Cart" - }, -} -const metadataLenses = { - label: "Metadata", - subitems: [ - "fixme", - "check_date", - "OSM_TIMESTAMP", - ] -}; -const allLensOptions = [ - { - label: "Attributes", - subitems: [ - "access", - "covered", - "dog", - "hand_cart", - "incline", - "lit", - "name", - "oneway", - "operator", - "sac_scale", - "smoothness", - "maxspeed", - "surface", - "trail_visibility", - "width", - ], - }, - { - label: "Waterway Attributes", - subitems: [ - "intermittent", - "open_water", - "rapids", - "tidal", - ] - }, - metadataLenses, -]; -const basicLensOptions = [ - { - label: "Attributes", - subitems: [ - "access", - "covered", - "dog", - "incline", - "lit", - "name", - "oneway", - "operator", - "smoothness", - "surface", - "trail_visibility", - "width", - ] - }, - metadataLenses, -]; -const vehicleLensOptions = [ - { - label: "Attributes", - subitems: [ - "access", - "covered", - "dog", - "incline", - "lit", - "name", - "oneway", - "operator", - "smoothness", - "maxspeed", - "surface", - "trail_visibility", - "width", - ] - }, - metadataLenses, -]; -const hikingLensOptions = [ - { - label: "Attributes", - subitems: [ - "access", - "covered", - "dog", - "incline", - "lit", - "name", - "oneway", - "operator", - "sac_scale", - "smoothness", - "surface", - "trail_visibility", - "width", - ] - }, - metadataLenses, -]; -const canoeLensOptions = [ - { - label: "Attributes", - subitems: [ - "access", - "covered", - "dog", - "name", - "oneway", - "width", - ] - }, - { - label: "Waterway Attributes", - subitems: [ - "intermittent", - "open_water", - "rapids", - "tidal", - ] - }, - { - label: "Portage Attributes", - subitems: [ - "hand_cart", - "incline", - "lit", - "operator", - "surface", - "smoothness", - "trail_visibility", - ] - }, - metadataLenses, -]; -const lensOptionsByMode = { - "all": allLensOptions, - "atv": vehicleLensOptions, - "bicycle": vehicleLensOptions, - "mtb": vehicleLensOptions, - "canoe": canoeLensOptions, - "foot": hikingLensOptions, - "horse": vehicleLensOptions, - "inline_skates": basicLensOptions, - "snowmobile": vehicleLensOptions, - "ski:nordic": basicLensOptions, - "wheelchair": basicLensOptions, -}; -function lensesForMode(travelMode) { - return lensOptionsByMode[travelMode].flatMap(function(item) { - return item.subitems; - }); -} -const highwayOnlyLenses = [ - "hand_cart", - "incline", - "lit", - "maxspeed", - "operator", - "sac_scale", - "smoothness", - "surface", - "trail_visibility", -]; -const waterwayOnlyLenses = [ - "tidal", - "intermittent", - "rapids", - "open_water", -]; -const defaultTravelMode = "all"; -const defaultLens= ""; -let travelMode = defaultTravelMode; -let lens = defaultLens; -let lastLens = defaultLens; - -let focusedEntityInfo; -let selectedEntityInfo; -let hoveredEntityInfo; - -function isValidEntityInfo(entityInfo) { - return ["node", "way", "relation"].includes(entityInfo?.type) && - entityInfo?.id > 0; -} - -function focusEntity(entityInfo, skipMapUpdate) { - if (!isValidEntityInfo(entityInfo)) entityInfo = null; - - if (focusedEntityInfo?.id === entityInfo?.id && - focusedEntityInfo?.type === entityInfo?.type - ) return; - - focusedEntityInfo = entityInfo; - - let bodyElement = document.getElementsByTagName('body')[0]; - - focusedEntityInfo ? bodyElement.classList.add('area-focused') : bodyElement.classList.remove('area-focused'); - - let type = focusedEntityInfo?.type; - let entityId = focusedEntityInfo?.id; - - setHashParameters({ - focus: focusedEntityInfo ? type + "/" + entityId : null - }); - - document.getElementById("map-title").innerText = ''; - document.getElementById("nameplate").style.display = focusedEntityInfo ? 'flex' : 'none'; - - if (!skipMapUpdate) { - reloadFocusAreaIfNeeded(); - updateMapForSelection(); - } -} - -function selectEntity(entityInfo, skipMapUpdate) { - - if (selectedEntityInfo?.id === entityInfo?.id && - selectedEntityInfo?.type === entityInfo?.type - ) return; - - selectedEntityInfo = entityInfo; - - let type = selectedEntityInfo?.type; - let entityId = selectedEntityInfo?.id; - - setHashParameters({ - selected: selectedEntityInfo ? type + "/" + entityId : null - }); - - if (!skipMapUpdate) { - updateMapForSelection(); - updateMapForHover(); - } - - if (isSidebarOpen()) updateSidebar(selectedEntityInfo); - - if (!selectedEntityInfo) return; - - if (type === "relation") { - fetchOsmEntity(type, entityId).then(function(entity) { - // update map again to add highlighting to any relation members - updateMapForSelection(); - }); - } -} - -function updateLensControl() { - let html = ""; - let items = lensOptionsByMode[travelMode]; - - html += ''; - items.forEach(function(item) { - if (item.subitems) { - html += ''; - item.subitems.forEach(function(item) { - let label = item.label ? item.label : lensStrings[item].label; - html += ''; - }) - html += ''; - } - }); - let lensElement = document.getElementById("lens"); - lensElement.innerHTML = html; - lensElement.value = lens; -} -function setTravelMode(value, skipMapUpdate) { - if (value === null) value = defaultTravelMode; - if (travelMode === value) return; - travelMode = value; - - if (!lensesForMode(travelMode).includes(lens)) setLens("", true); - - document.getElementById("travel-mode").value = travelMode; - - updateLensControl(); - if (!skipMapUpdate) reloadMapStyle(); - setHashParameters({ mode: travelMode === defaultTravelMode ? null : value }); -} -function setLens(value, skipMapUpdate) { - if (value === null) value = defaultLens; - if (!lensesForMode(travelMode).includes(value)) value = ""; - - if (lens === value) return; - lens = value; - - document.getElementById("lens").value = lens; - - if (!skipMapUpdate) reloadMapStyle(); - setHashParameters({ lens: lens === defaultLens ? null : value }); -} - -window.onload = function() { - - window.addEventListener("hashchange", function() { - updateForHash(false); - }); - - document.addEventListener('keydown', function(e) { - - if (e.isComposing || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; - - switch(e.key) { - case 'z': - let info = selectedEntityInfo || focusedEntityInfo; - let feature = info && getFeatureFromLayers(info.id, info.type, ['park', 'trail', 'trail_poi', {source: 'openmaptiles', layer: 'mountain_peak'}]) || info?.rawFeature; - if (feature) { - let bounds = getEntityBoundingBox(feature); - if (bounds) { - fitMapToBounds(bounds); - } else if (feature.geometry.type === "Point") { - map.flyTo({center: feature.geometry.coordinates, zoom: Math.max(map.getZoom(), 12)}); - } - } - break; - } - }); - - document.getElementById("travel-mode").addEventListener('change', function(e) { - setTravelMode(e.target.value); - }); - document.getElementById("lens").addEventListener('change', function(e) { - setLens(e.target.value); - }); - document.getElementById("inspect-toggle").addEventListener('click', function(e) { - e.preventDefault(); - toggleSidebar(); - }); - document.getElementById("clear-focus").addEventListener('click', function(e) { - e.preventDefault(); - focusEntity(); - }); - - updateLensControl(); - - // default - let initialCenter = [-111.545, 39.546]; - let initialZoom = 6; - - // show last-open area if any (this is overriden by the URL hash map parameter) - let cachedTransformString = localStorage?.getItem('map_transform'); - let cachedTransform = cachedTransformString && JSON.parse(cachedTransformString); - if (cachedTransform && cachedTransform.zoom && cachedTransform.lat && cachedTransform.lng) { - initialZoom = cachedTransform.zoom; - initialCenter = cachedTransform; - } - - map = new maplibregl.Map({ - container: 'map', - hash: "map", - center: initialCenter, - zoom: initialZoom, - fadeDuration: 0, - }); - - // Add zoom and rotation controls to the map. - map - .addControl(new maplibregl.NavigationControl({ - visualizePitch: true - })) - .addControl(new maplibregl.GeolocateControl({ - positionOptions: { - enableHighAccuracy: true - }, - trackUserLocation: true - })) - .addControl(new maplibregl.ScaleControl({ - maxWidth: 150, - unit: 'imperial' - }), "bottom-left"); - - loadInitialMap(); -} diff --git a/js/mapController.js b/js/mapController.js index 69d113f..1f4bb37 100644 --- a/js/mapController.js +++ b/js/mapController.js @@ -1,4 +1,12 @@ +import { state } from "./stateController.js"; +import { osm } from "./osmController.js"; +import { generateStyle } from './styleGenerator.js'; +import { createElement } from "./utils.js"; + +let map; + let activePopup; +let baseStyleJsonString; let cachedStyles = {}; @@ -6,6 +14,8 @@ let focusAreaGeoJson; let focusAreaGeoJsonBuffered; let focusAreaBoundingBox; +let hoveredEntityInfo; + const possibleLayerIdsByCategory = { clickable: ["trails-pointer-targets", "peaks", "trail-pois", "major-trail-pois", "trail-centerpoints"], hovered: ["hovered-paths", "hovered-peaks", "hovered-trail-centerpoints", "hovered-pois"], @@ -17,9 +27,77 @@ const layerIdsByCategory = { selected: [], }; -async function loadInitialMap() { +window.addEventListener('load', function() { + initializeMap(); + + state.addEventListener('inspectorOpenChange', function() { + if (state.inspectorOpen && activePopup) { + activePopup.remove(); + activePopup = null; + } + }); +}); + +async function initializeMap() { + + baseStyleJsonString = await fetch('/style/basestyle.json').then(response => response.text()); + + document.addEventListener('keydown', function(e) { + + if (e.isComposing || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; + + switch(e.key) { + case 'z': + let info = state.selectedEntityInfo || state.focusedEntityInfo; + let feature = info && getFeatureFromLayers(info.id, info.type, ['park', 'trail', 'trail_poi', {source: 'openmaptiles', layer: 'mountain_peak'}]) || info?.rawFeature; + if (feature) { + let bounds = getEntityBoundingBox(feature); + if (bounds) { + fitMapToBounds(bounds); + } else if (feature.geometry.type === "Point") { + map.flyTo({center: feature.geometry.coordinates, zoom: Math.max(map.getZoom(), 12)}); + } + } + break; + } + }); + + // default + let initialCenter = [-111.545, 39.546]; + let initialZoom = 6; + + // show last-open area if any (this is overriden by the URL hash map parameter) + let cachedTransformString = localStorage?.getItem('map_transform'); + let cachedTransform = cachedTransformString && JSON.parse(cachedTransformString); + if (cachedTransform && cachedTransform.zoom && cachedTransform.lat && cachedTransform.lng) { + initialZoom = cachedTransform.zoom; + initialCenter = cachedTransform; + } + + map = new maplibregl.Map({ + container: 'map', + hash: "map", + center: initialCenter, + zoom: initialZoom, + fadeDuration: 0, + }); + + // Add zoom and rotation controls to the map. + map + .addControl(new maplibregl.NavigationControl({ + visualizePitch: true + })) + .addControl(new maplibregl.GeolocateControl({ + positionOptions: { + enableHighAccuracy: true + }, + trackUserLocation: true + })) + .addControl(new maplibregl.ScaleControl({ + maxWidth: 150, + unit: 'imperial' + }), "bottom-left"); - updateForHash(true); reloadMapStyle(); map.on('mousemove', didMouseMoveMap); @@ -38,10 +116,32 @@ async function loadInitialMap() { reloadFocusAreaIfNeeded(); } }); + + state.addEventListener('travelModeChange', function() { + reloadMapStyle(); + }); + state.addEventListener('lensChange', function() { + reloadMapStyle(); + }); + state.addEventListener('selectedEntityChange', function() { + updateMapForSelection(); + + let selectedEntityInfo = state.selectedEntityInfo; + if (selectedEntityInfo && selectedEntityInfo?.type === "relation") { + osm.fetchOsmEntity(selectedEntityInfo.type, selectedEntityInfo.id).then(function() { + // update map again to add highlighting to any relation members + updateMapForSelection(); + }); + } + }); + state.addEventListener('focusedEntityChange', function() { + reloadFocusAreaIfNeeded(); + updateMapForSelection(); + }); } function getStyleId() { - return travelMode + '/' + lens; + return state.travelMode + '/' + state.lens; } function getCachedStyleLayer(layerId) { @@ -49,10 +149,12 @@ function getCachedStyleLayer(layerId) { return cachedStyle.layers.find(layer => layer.id === layerId); } -async function reloadMapStyle() { +function reloadMapStyle() { + + if (!baseStyleJsonString) return; let styleId = getStyleId(); - if (!cachedStyles[styleId]) cachedStyles[styleId] = JSON.stringify(await generateStyle(travelMode, lens)); + if (!cachedStyles[styleId]) cachedStyles[styleId] = JSON.stringify(generateStyle(baseStyleJsonString, state.travelMode, state.lens)); // always parse from string to avoid stale referenced objects let style = JSON.parse(cachedStyles[styleId]); @@ -75,6 +177,7 @@ async function reloadMapStyle() { } function reloadFocusAreaIfNeeded() { + let focusedEntityInfo = state.focusedEntityInfo; let newFocusAreaGeoJson = buildFocusAreaGeoJson(); if ((newFocusAreaGeoJson && JSON.stringify(newFocusAreaGeoJson)) !== @@ -145,6 +248,7 @@ function updateMapForFocus() { } function styleAddendumsForFocus() { + let focusedEntityInfo = state.focusedEntityInfo; let focusedId = focusedEntityInfo?.id ? omtId(focusedEntityInfo.id, focusedEntityInfo.type) : null; return { "trail-pois": { @@ -161,16 +265,14 @@ function styleAddendumsForFocus() { getCachedStyleLayer('major-trail-pois').filter, // don't show icon and label for currently focused feature ["!=", ["get", "OSM_ID"], focusedEntityInfo ? focusedEntityInfo.id : null], - ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? - focusAreaFilter = [["within", focusAreaGeoJsonBuffered]] : []), + ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? [["within", focusAreaGeoJsonBuffered]] : []), ], }, "peaks": { "filter": [ "all", getCachedStyleLayer('peaks').filter, - ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? - focusAreaFilter = [["within", focusAreaGeoJsonBuffered]] : []), + ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? [["within", focusAreaGeoJsonBuffered]] : []), ] }, "park-fill": { @@ -215,9 +317,13 @@ function styleAddendumsForFocus() { function updateMapForSelection() { applyStyleAddendumsToMap(styleAddendumsForSelection()); + updateMapForHover(); } function styleAddendumsForSelection() { + let selectedEntityInfo = state.selectedEntityInfo; + let focusedEntityInfo = state.focusedEntityInfo; + let id = selectedEntityInfo && selectedEntityInfo.id; let type = selectedEntityInfo && selectedEntityInfo.type; @@ -226,13 +332,13 @@ function styleAddendumsForSelection() { let idsToHighlight = [id && id !== focusedId ? id : -1]; if (type === "relation") { - let members = osmEntityCache[type[0] + id]?.members || []; + let members = osm.getCachedEntity(type, id)?.members || []; members.forEach(function(member) { if (member.role !== 'inner') idsToHighlight.push(member.ref); if (member.type === "relation") { // only recurse down if we have the entity cached - let childRelationMembers = osmEntityCache[member.type[0] + member.ref]?.members || []; + let childRelationMembers = osm.getCachedEntity(member.type, member.ref)?.members || []; childRelationMembers.forEach(function(member) { idsToHighlight.push(member.ref); // don't recurse relations again in case of self-references @@ -263,6 +369,8 @@ function updateMapForHover() { function styleAddendumsForHover() { + let selectedEntityInfo = state.selectedEntityInfo; + let entityId = hoveredEntityInfo?.id || -1; if (hoveredEntityInfo?.id == selectedEntityInfo?.id && @@ -312,9 +420,9 @@ function entityForEvent(e, layerIds) { function didClickMap(e) { let entity = entityForEvent(e, layerIdsByCategory.clickable); - selectEntity(entity); + state.selectEntity(entity); - if (!entity || isSidebarOpen()) return; + if (!entity || state.inspectorOpen) return; let coordinates = entity.focusLngLat; @@ -327,22 +435,34 @@ function didClickMap(e) { let tags = entity.rawFeature.properties; - let html = ""; - - if (tags.name) html += "" + tags.name + "
" - html += 'View Details'; + let div = createElement('div'); + if (tags.name) { + div.append( + createElement('b') + .append(tags.name), + createElement('br') + ); + } + div.append( + createElement('a') + .setAttribute('href', '#') + .setAttribute('class', 'button') + .addEventListener('click', didClickViewDetails) + .append('View Details') + ); activePopup = new maplibregl.Popup({ className: 'quickinfo', closeButton: false, }) .setLngLat(coordinates) - .setHTML(html) + .setDOMContent(div) .addTo(map); } -function didClickViewDetails() { - openSidebar(); +function didClickViewDetails(e) { + e.preventDefault(); + state.setInspectorOpen(true); return false; } @@ -351,7 +471,7 @@ function didDoubleClickMap(e) { let entity = entityForEvent(e, ['major-trail-pois']); if (entity) { e.preventDefault(); - focusEntity(entity); + state.focusEntity(entity); } } @@ -425,6 +545,7 @@ function getFeatureFromLayers(id, type, layers) { } function getEntityBoundingBoxFromLayer(id, type, layer) { + let focusedEntityInfo = state.focusedEntityInfo; if (!focusedEntityInfo) return null; let feature = getFeatureFromLayers(id, type, [layer]); if (feature) { @@ -433,6 +554,7 @@ function getEntityBoundingBoxFromLayer(id, type, layer) { } function buildFocusAreaGeoJson() { + let focusedEntityInfo = state.focusedEntityInfo; if (!focusedEntityInfo) return null; let results = map.querySourceFeatures('trails', { filter: [ @@ -470,3 +592,12 @@ function fitMapToBounds(bbox) { let fitBbox = extendBbox(bbox, maxExtent / 16); map.fitBounds(fitBbox); } + +function extendBbox(bbox, buffer) { + bbox = bbox.slice(); + bbox[0] -= buffer; // west + bbox[1] -= buffer; // south + bbox[2] += buffer; // east + bbox[3] += buffer; // north + return bbox; +} \ No newline at end of file diff --git a/js/optionsData.js b/js/optionsData.js new file mode 100644 index 0000000..885a6f2 --- /dev/null +++ b/js/optionsData.js @@ -0,0 +1,227 @@ +// Data objects for the options shown in the UI. + +export const lensStrings = { + access: { + label: "Access" + }, + covered: { + label: "Covered" + }, + dog: { + label: "Dog Access" + }, + incline: { + label: "Incline" + }, + lit: { + label: "Lit" + }, + maxspeed: { + label: "Speed Limit" + }, + name: { + label: "Name" + }, + oneway: { + label: "Oneway" + }, + operator: { + label: "Operator" + }, + sac_scale: { + label: "SAC Hiking Scale" + }, + smoothness: { + label: "Smoothness" + }, + surface: { + label: "Surface" + }, + trail_visibility: { + label: "Trail Visibility" + }, + width: { + label: "Width" + }, + fixme: { + label: "Fixme Requests" + }, + check_date: { + label: "Last Checked Date" + }, + OSM_TIMESTAMP: { + label: "Last Edited Date" + }, + intermittent: { + label: "Intermittent" + }, + open_water: { + label: "Open Water" + }, + rapids: { + label: "Rapids" + }, + tidal: { + label: "Tidal" + }, + hand_cart: { + label: "Hand Cart" + }, +}; + +export const metadataLenses = { + label: "Metadata", + subitems: [ + "fixme", + "check_date", + "OSM_TIMESTAMP", + ] +}; + +export const allLensOptions = [ + { + label: "Attributes", + subitems: [ + "access", + "covered", + "dog", + "hand_cart", + "incline", + "lit", + "name", + "oneway", + "operator", + "sac_scale", + "smoothness", + "maxspeed", + "surface", + "trail_visibility", + "width", + ], + }, + { + label: "Waterway Attributes", + subitems: [ + "intermittent", + "open_water", + "rapids", + "tidal", + ] + }, + metadataLenses, +]; + +export const basicLensOptions = [ + { + label: "Attributes", + subitems: [ + "access", + "covered", + "dog", + "incline", + "lit", + "name", + "oneway", + "operator", + "smoothness", + "surface", + "trail_visibility", + "width", + ] + }, + metadataLenses, +]; + +export const vehicleLensOptions = [ + { + label: "Attributes", + subitems: [ + "access", + "covered", + "dog", + "incline", + "lit", + "name", + "oneway", + "operator", + "smoothness", + "maxspeed", + "surface", + "trail_visibility", + "width", + ] + }, + metadataLenses, +]; + +export const hikingLensOptions = [ + { + label: "Attributes", + subitems: [ + "access", + "covered", + "dog", + "incline", + "lit", + "name", + "oneway", + "operator", + "sac_scale", + "smoothness", + "surface", + "trail_visibility", + "width", + ] + }, + metadataLenses, +]; + +export const canoeLensOptions = [ + { + label: "Attributes", + subitems: [ + "access", + "covered", + "dog", + "name", + "oneway", + "width", + ] + }, + { + label: "Waterway Attributes", + subitems: [ + "intermittent", + "open_water", + "rapids", + "tidal", + ] + }, + { + label: "Portage Attributes", + subitems: [ + "hand_cart", + "incline", + "lit", + "operator", + "surface", + "smoothness", + "trail_visibility", + ] + }, + metadataLenses, +]; + +export const lensOptionsByMode = { + "all": allLensOptions, + "atv": vehicleLensOptions, + "bicycle": vehicleLensOptions, + "mtb": vehicleLensOptions, + "canoe": canoeLensOptions, + "foot": hikingLensOptions, + "horse": vehicleLensOptions, + "inline_skates": basicLensOptions, + "snowmobile": vehicleLensOptions, + "ski:nordic": basicLensOptions, + "wheelchair": basicLensOptions, +}; diff --git a/js/osmController.js b/js/osmController.js new file mode 100644 index 0000000..5ac3098 --- /dev/null +++ b/js/osmController.js @@ -0,0 +1,79 @@ +export class OsmController { + + osmEntityCache = {}; + osmEntityMembershipCache = {}; + osmChangesetCache = {}; + + cacheEntities(elements, full) { + for (let i in elements) { + let element = elements[i]; + let type = element.type; + let id = element.id; + let key = type[0] + id; + + this.osmEntityCache[key] = element; + this.osmEntityCache[key].full = full; + } + } + + getCachedEntity(type, id) { + return this.osmEntityCache[type[0] + id]; + } + + async fetchOsmEntity(type, id) { + let key = type[0] + id; + if (!this.osmEntityCache[key] || !this.osmEntityCache[key].full) { + let url = `https://api.openstreetmap.org/api/0.6/${type}/${id}`; + if (type !== 'node') { + url += '/full'; + } + url += '.json'; + let response = await fetch(url); + let json = await response.json(); + this.cacheEntities(json && json.elements || [], true); + } + return this.osmEntityCache[key]; + } + + async fetchOsmEntityMemberships(type, id) { + let key = type[0] + id; + + if (!this.osmEntityMembershipCache[key]) { + let response = await fetch(`https://api.openstreetmap.org/api/0.6/${type}/${id}/relations.json`); + let json = await response.json(); + let rels = json && json.elements || []; + + this.osmEntityMembershipCache[key] = []; + for (let i in rels) { + let rel = rels[i]; + for (let j in rel.members) { + let membership = rel.members[j]; + if (membership.ref === id && membership.type === type) { + this.osmEntityMembershipCache[key].push({ + type: rel.type, + id: rel.id, + role: membership.role, + }); + } + } + } + // response relations are fully defined entities so we can cache them for free + this.cacheEntities(rels, false); + } + + return this.osmEntityMembershipCache[key]; + } + + async fetchOsmChangeset(id) { + if (!this.osmChangesetCache[id]) { + let url = `https://api.openstreetmap.org/api/0.6/changeset/${id}.json`; + let response = await fetch(url); + let json = await response.json(); + this.osmChangesetCache[id] = json && json.changeset; + } + return this.osmChangesetCache[id]; + } + +} + +export const osm = new OsmController(); \ No newline at end of file diff --git a/js/sidebarController.js b/js/sidebarController.js index 09cef30..462dc02 100644 --- a/js/sidebarController.js +++ b/js/sidebarController.js @@ -1,28 +1,19 @@ +import { osm } from "./osmController.js"; +import { state } from "./stateController.js"; +import { createElement } from "./utils.js"; + function isSidebarOpen() { return document.getElementsByTagName('body')[0].classList.contains('sidebar-open'); } -function toggleSidebar(toOpen) { - if (isSidebarOpen()) { - closeSidebar(); - } else { - openSidebar(); - } -} function openSidebar() { if (!isSidebarOpen()) { - if (activePopup) { - activePopup.remove(); - activePopup = null; - } document.getElementsByTagName('body')[0].classList.add('sidebar-open'); - setHashParameters({ inspect: 1 }); - updateSidebar(selectedEntityInfo); + updateSidebar(state.selectedEntityInfo); } } function closeSidebar() { if (isSidebarOpen()) { document.getElementsByTagName('body')[0].classList.remove('sidebar-open'); - setHashParameters({ inspect: null }); } } @@ -41,10 +32,10 @@ function updateSidebar(entity) { let focusLngLat = entity.focusLngLat; let bbox = focusLngLat && { - left: left = focusLngLat.lng - 0.001, - right: right = focusLngLat.lng + 0.001, - bottom: left = focusLngLat.lat - 0.001, - top: right = focusLngLat.lat + 0.001, + left: focusLngLat.lng - 0.001, + right: focusLngLat.lng + 0.001, + bottom: focusLngLat.lat - 0.001, + top: focusLngLat.lat + 0.001, }; let opQuery = encodeURIComponent(`${type}(${entityId});\n(._;>;);\nout;`); @@ -84,9 +75,9 @@ function updateSidebar(entity) { sidebarElement.innerHTML = html; - fetchOsmEntity(type, entityId).then(function(entity) { + osm.fetchOsmEntity(type, entityId).then(function(entity) { if (entity) { - fetchOsmChangeset(entity.changeset).then(function(changeset) { + osm.fetchOsmChangeset(entity.changeset).then(function(changeset) { updateMetaTable(entity, changeset); }); } @@ -94,7 +85,7 @@ function updateSidebar(entity) { if (tags) updateTagsTable(tags); }); - fetchOsmEntityMemberships(type, entityId).then(function(memberships) { + osm.fetchOsmEntityMemberships(type, entityId).then(function(memberships) { updateMembershipsTable(memberships); }); } @@ -150,37 +141,85 @@ function updateTagsTable(tags) { } function updateMembershipsTable(memberships) { - const element = document.getElementById('relations-table'); - if (!element) return; - - let html = ""; + const table = document.getElementById('relations-table'); + if (!table) return; + table.innerHTML = ""; if (memberships.length) { - html += `RelationTypeRole`; + table.append( + createElement('tr') + .append( + createElement('th') + .append('Relation'), + createElement('th') + .append('Type'), + createElement('th') + .append('Role') + ) + ); for (let i in memberships) { let membership = memberships[i]; - let rel = osmEntityCache[membership.key]; + let rel = osm.getCachedEntity(membership.type, membership.id); let label = rel.tags.name || rel.id; let relType = rel.tags.type || ''; if ((relType === "route" || relType === "superroute") && rel.tags.route) { relType += " (" + (rel.tags.route || rel.tags.superroute) + ")"; } - html += `${label}${relType}${membership.role}`; + table.append( + createElement('tr') + .append( + createElement('td') + .append( + createElement('a') + .setAttribute('href', '#') + .setAttribute('type', membership.type) + .setAttribute('id', membership.id) + .addEventListener('click', didClickEntityLink) + .append(label) + ), + createElement('td') + .append(relType), + createElement('td') + .append(membership.role) + ) + ); } } else { + let html = ""; html += `Relations`; html += `none`; + table.innerHTML = html; } - element.innerHTML = html; } function didClickEntityLink(e) { e.preventDefault(); - selectEntity(osmEntityCache[e.target.getAttribute("key")]); + state.selectEntity(osm.getCachedEntity(e.target.getAttribute("type"), e.target.getAttribute("id"))); } function getFormattedDate(date) { let offsetDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)); let components = offsetDate.toISOString().split('T') return components[0] + " " + components[1].split(".")[0]; -} \ No newline at end of file +} + +window.addEventListener('load', function() { + + document.getElementById("inspect-toggle").addEventListener('click', function(e) { + e.preventDefault(); + state.setInspectorOpen(!isSidebarOpen()); + }); + + state.addEventListener('selectedEntityChange', function() { + if (isSidebarOpen()) updateSidebar(state.selectedEntityInfo); + }); + + state.addEventListener('inspectorOpenChange', function() { + if (state.inspectorOpen) { + openSidebar(); + } else { + closeSidebar(); + } + }); + +}); diff --git a/js/stateController.js b/js/stateController.js new file mode 100644 index 0000000..6e03714 --- /dev/null +++ b/js/stateController.js @@ -0,0 +1,91 @@ +// Manages the state of the UI in a generalized sort of way. +// The various UI components can listen for state changes and +// update themselves accordingly. + +import { lensOptionsByMode } from "./optionsData.js"; + +const defaultTravelMode = "all"; +const defaultLens = ""; + +function isValidEntityInfo(entityInfo) { + return ["node", "way", "relation"].includes(entityInfo?.type) && + entityInfo?.id > 0; +} + +function lensesForMode(travelMode) { + return lensOptionsByMode[travelMode].flatMap(function(item) { + return item.subitems; + }); +} + +class StateController extends EventTarget { + + defaultTravelMode = defaultTravelMode + defaultLens = defaultLens; + travelMode = defaultTravelMode; + lens = defaultLens; + + inspectorOpen = false; + + focusedEntityInfo; + selectedEntityInfo; + + focusEntity(entityInfo) { + if (!isValidEntityInfo(entityInfo)) entityInfo = null; + + if (this.focusedEntityInfo?.id === entityInfo?.id && + this.focusedEntityInfo?.type === entityInfo?.type + ) return; + + this.focusedEntityInfo = entityInfo; + + this.dispatchEvent(new Event('focusedEntityChange')); + + let bodyElement = document.getElementsByTagName('body')[0]; + + this.focusedEntityInfo ? bodyElement.classList.add('area-focused') : bodyElement.classList.remove('area-focused'); + + document.getElementById("map-title").innerText = ''; + document.getElementById("nameplate").style.display = this.focusedEntityInfo ? 'flex' : 'none'; + } + + selectEntity(entityInfo) { + + if (this.selectedEntityInfo?.id === entityInfo?.id && + this.selectedEntityInfo?.type === entityInfo?.type + ) return; + + this.selectedEntityInfo = entityInfo; + + this.dispatchEvent(new Event('selectedEntityChange')); + } + + setTravelMode(value) { + if (value === null) value = defaultTravelMode; + if (this.travelMode === value) return; + this.travelMode = value; + if (!lensesForMode(value).includes(this.lens)) this.setLens(defaultLens); + + this.dispatchEvent(new Event('travelModeChange')); + } + + setLens(value) { + if (value === null) value = defaultLens; + if (!lensesForMode(this.travelMode).includes(value)) value = defaultLens; + + if (this.lens === value) return; + this.lens = value; + + this.dispatchEvent(new Event('lensChange')); + } + + setInspectorOpen(value) { + value = !!value; + if (this.inspectorOpen === value) return; + this.inspectorOpen = value; + + this.dispatchEvent(new Event('inspectorOpenChange')); + } +} + +export const state = new StateController(); diff --git a/js/styleGenerator.js b/js/styleGenerator.js index 69fca1e..6ae544e 100644 --- a/js/styleGenerator.js +++ b/js/styleGenerator.js @@ -1,12 +1,26 @@ -let baseStyleJsonString; - -async function generateStyle(travelMode, lens) { - - if (!baseStyleJsonString) baseStyleJsonString = await fetch('./style/basestyle.json').then(response => response.text()); +export function generateStyle(baseStyleJsonString, travelMode, lens) { // parse anew every time to avoid object references const style = JSON.parse(baseStyleJsonString); + const highwayOnlyLenses = [ + "hand_cart", + "incline", + "lit", + "maxspeed", + "operator", + "sac_scale", + "smoothness", + "surface", + "trail_visibility", + ]; + const waterwayOnlyLenses = [ + "tidal", + "intermittent", + "rapids", + "open_water", + ]; + const poiLabelZoom = 14; const thisYear = new Date().getFullYear(); const colors = { diff --git a/js/utils.js b/js/utils.js index e7b5bc0..c6fea88 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,34 +1,16 @@ -function getMaxArrayDepth(value) { - return Array.isArray(value) ? - 1 + Math.max(0, ...value.map(getMaxArrayDepth)) : - 0; -} -function bboxOfGeoJson(geojson) { - - if (!geojson?.geometry?.coordinates?.length) return; - - let depth = getMaxArrayDepth(geojson.geometry.coordinates); - let coords = geojson.geometry.coordinates.flat(depth - 2); - let bbox = [ Infinity, Infinity, -Infinity, -Infinity]; - - bbox = coords.reduce(function(prev, coord) { - return [ - Math.min(coord[0], prev[0]), - Math.min(coord[1], prev[1]), - Math.max(coord[0], prev[2]), - Math.max(coord[1], prev[3]) - ]; - }, bbox); - if (bbox[0].isNaN) return; - return bbox; -}; - -function extendBbox(bbox, buffer) { - bbox = bbox.slice(); - bbox[0] -= buffer; // west - bbox[1] -= buffer; // south - bbox[2] += buffer; // east - bbox[3] += buffer; // north - return bbox; +// Creates a new HTML element but wraps certain function so they return the +// element itself in order to enable chaining. +export function createElement(...args) { + let el = document.createElement(...args) + let fnNames = ['setAttribute', 'addEventListener', 'append', 'appendChild']; + for (let i in fnNames) { + let fnName = fnNames[i]; + let fn = el[fnName]; + el[fnName] = function(...args) { + fn.apply(this, args); + return el; + }; + } + return el; } \ No newline at end of file