diff --git a/.eslintignore b/.eslintignore index d895d7c1..1e39cdea 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,3 +11,7 @@ src/serviceWorker.ts # Copied from @urbica/react-map-gl src/__mocks__/mapbox-gl.js + +# Modules from external sources +src/minimal-xyz-viewer.js +src/RoutableTilesToGeoJSON.js diff --git a/src/App.tsx b/src/App.tsx index 1acff455..df965d43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { routeImaginaryLineLayer, allEntrancesLayer, allEntrancesSymbolLayer, + routableTilesLayer, } from "./map-style"; import Pin, { pinAsSVG } from "./components/Pin"; import UserPosition from "./components/UserPosition"; @@ -28,6 +29,9 @@ import GeolocateControl from "./components/GeolocateControl"; import calculatePlan, { geometryToGeoJSON } from "./planner"; import { queryEntrances, ElementWithCoordinates } from "./overpass"; import { addImageSVG, getMapSize } from "./mapbox-utils"; +import routableTilesToGeoJSON from "./RoutableTilesToGeoJSON"; +import { getVisibleTiles } from "./minimal-xyz-viewer"; + import "./App.css"; import "./components/PinMarker.css"; @@ -46,6 +50,7 @@ interface State { geolocationPosition: LatLng | null; popupCoordinates: LatLng | null; snackbar?: ReactText; + routableTiles: Map; } const latLngToDestination = (latLng: LatLng): ElementWithCoordinates => ({ @@ -74,6 +79,7 @@ const initialState: State = { isGeolocating: false, geolocationPosition: null, popupCoordinates: null, + routableTiles: new Map(), }; const metropolitanAreaCenter = [60.17066815612902, 24.941510260105133]; @@ -173,6 +179,73 @@ const App: React.FC = () => { ); }; + useEffect(() => { + if (!map.current || !state.viewport.zoom) { + return; // Nothing to do yet + } + if (state.viewport.zoom < 12) return; // minzoom + + const { width: mapWidth, height: mapHeight } = getMapSize( + map.current.getMap() + ); + + // Calculate multiplier for under- or over-zoom + const tilesetZoomLevel = 14; + const zoomOffset = 1; // tiles are 512px (double the standard size) + const zoomMultiplier = + 2 ** (tilesetZoomLevel - zoomOffset - state.viewport.zoom); + + const visibleTiles = getVisibleTiles( + zoomMultiplier * mapWidth, + zoomMultiplier * mapHeight, + [state.viewport.longitude, state.viewport.latitude], + tilesetZoomLevel + ); + + // Initialise the new Map with nulls and available tiles from previous + const routableTiles = new Map(); + visibleTiles.forEach(({ zoom, x, y }) => { + const key = `${zoom}/${x}/${y}`; + routableTiles.set(key, state.routableTiles.get(key) || null); + }); + + setState( + (prevState: State): State => { + return { + ...prevState, + routableTiles, + }; + } + ); + + visibleTiles.map(async ({ zoom, x, y }) => { + const key = `${zoom}/${x}/${y}`; + if (routableTiles.get(key) !== null) return; // We already have the tile + // Fetch the tile + const response = await fetch( + `https://tile.olmap.org/routable-tiles/${zoom}/${x}/${y}` + ); + const body = await response.json(); + // Convert the tile to GeoJSON + const geoJSON = routableTilesToGeoJSON(body) as FeatureCollection; + // Add the tile if still needed based on latest state + setState( + (prevState: State): State => { + if (prevState.routableTiles.get(key) !== null) { + return prevState; // This tile is not needed anymore + } + const newRoutableTiles = new Map(prevState.routableTiles); + newRoutableTiles.set(key, geoJSON); + return { + ...prevState, + routableTiles: newRoutableTiles, + }; + } + ); + }); + }, [map.current, state.viewport]); // eslint-disable-line react-hooks/exhaustive-deps + // XXX: state.routableTiles is missing above as we only use it as a cache here + useEffect(() => { /** * FIXME: urbica/react-map-gl does not expose fitBounds and its viewport @@ -548,6 +621,25 @@ const App: React.FC = () => { )} + {Array.from( + state.routableTiles.entries(), + ([coords, tile]) => + tile && ( + + + + ) + )} { + return item["@type"] === "osm:Way"; + }) + .map((item) => { + //Transform osm:hasNodes to a linestring style thing + if (!item["osm:hasNodes"]) { + item["osm:hasNodes"] = []; + } else if (typeof item["osm:hasNodes"] === "string") { + item["osm:hasNodes"] = [item["osm:hasNodes"]]; + } + item["osm:hasNodes"] = item["osm:hasNodes"].map((node) => { + return nodes[node]; + }); + let geometry = { + type: "LineString", + coordinates: item["osm:hasNodes"], + }; + return { + id: item["@id"], + //layer: item['osm:highway'], + type: "Feature", + properties: { + highway: item["osm:highway"], + name: item["rdfs:label"] ? item["rdfs:label"] : "", + }, + geometry: geometry, + }; + }); +}; + +module.exports = function (json) { + // Normalize feature getters into actual instanced features + var feats = []; + var nodes = {}; + for (var i = 0; i < json["@graph"].length; i++) { + let o = json["@graph"][i]; + if (o["geo:lat"] && o["geo:long"]) { + nodes[o["@id"]] = [o["geo:long"], o["geo:lat"]]; + let feature = { + id: o["@id"], + type: "Feature", + geometry: { + type: "Point", + coordinates: [o["geo:long"], o["geo:lat"]], + }, + }; + feats.push(feature); + } + } + let ways = extractWays(json, nodes); + return { + type: "FeatureCollection", + features: ways, + }; +}; diff --git a/src/map-style.ts b/src/map-style.ts index f5f042c6..63c0df3b 100644 --- a/src/map-style.ts +++ b/src/map-style.ts @@ -30,6 +30,15 @@ export const routeImaginaryLineLayer: LayerProps = { }, filter: ["coalesce", ["get", "imaginary"], false], }; +export const routableTilesLayer = { + id: "routable-tiles-line", + type: "line", + paint: { + "line-opacity": ["coalesce", ["get", "opacity"], 0.5], + "line-width": 2, + "line-color": "black", + }, +}; export const routePointLayer: LayerProps = { id: "route-point", diff --git a/src/minimal-xyz-viewer.js b/src/minimal-xyz-viewer.js new file mode 100644 index 00000000..46e4509a --- /dev/null +++ b/src/minimal-xyz-viewer.js @@ -0,0 +1,42 @@ +// Source: https://gist.github.com/jsanz/e8549c7bffd442235a942695ffdaf77d + +const TILE_SIZE = 256; +const WEBMERCATOR_R = 6378137.0; +const DIAMETER = WEBMERCATOR_R * 2 * Math.PI; + +function mercatorProject(lonlat) { + var x = (DIAMETER * lonlat[0]) / 360.0; + var sinlat = Math.sin((lonlat[1] * Math.PI) / 180.0); + var y = (DIAMETER * Math.log((1 + sinlat) / (1 - sinlat))) / (4 * Math.PI); + return [DIAMETER / 2 + x, DIAMETER - (DIAMETER / 2 + y)]; +} +// console.log(Mercator.project([-3,41])) + +export function getVisibleTiles(clientWidth, clientHeight, center, zoom) { + var centerm = mercatorProject(center); + // zoom + centerm -> centerpx + var centerpx = [ + (centerm[0] * TILE_SIZE * Math.pow(2, zoom)) / DIAMETER, + (centerm[1] * TILE_SIZE * Math.pow(2, zoom)) / DIAMETER, + ]; + + // xmin, ymin, xmax, ymax + var bbox = [ + Math.floor((centerpx[0] - clientWidth / 2) / TILE_SIZE), + Math.floor((centerpx[1] - clientHeight / 2) / TILE_SIZE), + Math.ceil((centerpx[0] + clientWidth / 2) / TILE_SIZE), + Math.ceil((centerpx[1] + clientHeight / 2) / TILE_SIZE), + ]; + var tiles = []; + //xmin, ymin, xmax, ymax + for (let x = bbox[0]; x < bbox[2]; ++x) { + for (let y = bbox[1]; y < bbox[3]; ++y) { + var [px, py] = [ + x * TILE_SIZE - centerpx[0] + clientWidth / 2, + y * TILE_SIZE - centerpx[1] + clientHeight / 2, + ]; + tiles.push({ x, y, zoom, px, py }); + } + } + return tiles; +}