modmapper-web/components/Map.tsx
Tyler Hallada 236b4c84ca SearchProvider singleton, fix lag with addAllAsync
Load modSearch data asynchronously and only rebuild the search if the page is refreshed. Fixes the lag when returning to the base sidebar page from a data page.
2022-08-27 17:01:16 -04:00

906 lines
26 KiB
TypeScript

import React, { useCallback, useRef, useEffect, useState } from "react";
import { useRouter } from "next/router";
import Gradient from "javascript-color-gradient";
import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable";
import { useAppDispatch, useAppSelector } from "../lib/hooks";
import { setSelectedFetchedPlugin, PluginFile } from "../slices/plugins";
import styles from "../styles/Map.module.css";
import Sidebar from "./Sidebar";
import ToggleLayersControl from "./ToggleLayersControl";
import SearchBar from "./SearchBar";
import SearchProvider from "./SearchProvider";
import { csvFetcher, jsonFetcherWithLastModified } from "../lib/api";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
const colorGradient = new Gradient();
colorGradient.setGradient(
"#0000FF",
"#00FF00",
"#FFFF00",
"#FFA500",
"#FF0000"
);
colorGradient.setMidpoint(360);
const LIVE_DOWNLOAD_COUNTS_URL =
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
const Map: React.FC = () => {
const router = useRouter();
const mapContainer = useRef<HTMLDivElement | null>(
null
) as React.MutableRefObject<HTMLDivElement>;
const map = useRef<mapboxgl.Map | null>(
null
) as React.MutableRefObject<mapboxgl.Map>;
const mapWrapper = useRef<HTMLDivElement | null>(
null
) as React.MutableRefObject<HTMLDivElement>;
const [mapLoaded, setMapLoaded] = useState<boolean>(false);
const [heatmapLoaded, setHeatmapLoaded] = useState<boolean>(false);
const [selectedCell, setSelectedCell] = useState<{
x: number;
y: number;
} | null>(null);
const [selectedCells, setSelectedCells] = useState<
| {
x: number;
y: number;
}[]
| null
>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const dispatch = useAppDispatch();
const parsedPlugins = useAppSelector((state) => state.plugins.parsedPlugins);
const fetchedPlugins = useAppSelector(
(state) => state.plugins.fetchedPlugins
);
const pluginsPending = useAppSelector((state) => state.plugins.pending);
const selectedFetchedPlugin = useAppSelector(
(state) => state.plugins.selectedFetchedPlugin
);
const { data: cellsData, error: cellsError } = useSWRImmutable(
"https://cells.modmapper.com/edits.json",
(_) => jsonFetcherWithLastModified<Record<string, number>>(_)
);
// The live download counts are not really immutable, but I'd still rather load them once per session
const [counts, setCounts] = useState<Record<
number,
[number, number, number]
> | null>(null);
const { data: countsData, error: countsError } = useSWRImmutable(
LIVE_DOWNLOAD_COUNTS_URL,
csvFetcher
);
const selectMapCell = useCallback(
(cell: { x: number; y: number }) => {
if (!map.current) return;
if (map.current && !map.current.getSource("grid-source")) return;
// map.current.removeFeatureState({ source: "grid-source" });
map.current.setFeatureState(
{
source: "grid-source",
id: (cell.x + 57) * 1000 + 50 - cell.y,
},
{
selected: true,
}
);
map.current.removeFeatureState({ source: "selected-cell-source" });
// map.current.removeFeatureState({ source: "conflicted-cell-source" });
map.current.setFeatureState(
{
source: "selected-cell-source",
id: (cell.x + 57) * 1000 + 50 - cell.y,
},
{
cellSelected: true,
// modSelected: false,
}
);
requestAnimationFrame(() => map.current && map.current.resize());
const panTo = () => {
const zoom = map.current.getZoom();
const viewportNW = map.current.project([-180, 85.051129]);
const cellSize = Math.pow(2, zoom + 2);
const x = cell.x + 57;
const y = 50 - cell.y;
let nw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y,
]);
let se = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y + cellSize,
]);
const bounds = map.current.getBounds();
if (!bounds.contains(nw) || !bounds.contains(se)) {
map.current.panTo(nw);
}
};
const bearing = map.current.getBearing();
const pitch = map.current.getPitch();
// This logic breaks with camera rotation / pitch
if (bearing !== 0 || pitch !== 0) {
map.current.easeTo({ bearing: 0, pitch: 0, duration: 300 });
setTimeout(() => {
panTo();
}, 300);
} else {
panTo();
}
},
[map]
);
const selectCells = useCallback(
(cells: { x: number; y: number }[], { fitCells } = { fitCells: false }) => {
if (!map.current) return;
if (map.current && !map.current.getSource("grid-source")) return;
map.current.removeFeatureState({ source: "selected-cells-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
const visited: { [id: number]: boolean } = {};
for (let cell of cells) {
const id = (cell.x + 57) * 1000 + 50 - cell.y;
map.current.setFeatureState(
{
source: "selected-cells-source",
id,
},
{
modSelected: true,
}
);
map.current.setFeatureState(
{
source: "conflicted-cell-source",
id,
},
{
conflicted: visited[id] === true ? true : false,
}
);
visited[id] = true;
}
if (fitCells) {
let bounds: mapboxgl.LngLatBounds | null = null;
const fitBounds = () => {
const zoom = map.current.getZoom();
const viewportNW = map.current.project([-180, 85.051129]);
const cellSize = Math.pow(2, zoom + 2);
for (const cell of cells) {
const x = cell.x + 57;
const y = 50 - cell.y;
let ne = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y,
]);
let sw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
if (bounds) {
bounds.extend(new mapboxgl.LngLatBounds(sw, ne));
} else {
bounds = new mapboxgl.LngLatBounds(sw, ne);
}
}
requestAnimationFrame(() => {
if (map.current) {
map.current.resize();
if (bounds) {
map.current.fitBounds(bounds, { padding: 40 });
}
}
});
};
const bearing = map.current.getBearing();
const pitch = map.current.getPitch();
// This logic breaks with camera rotation / pitch
if (bearing !== 0 || pitch !== 0) {
map.current.easeTo({ bearing: 0, pitch: 0, duration: 300 });
setTimeout(() => {
fitBounds();
}, 300);
} else {
fitBounds();
}
}
},
[map]
);
const selectCell = useCallback(
(cell) => {
router.push({ query: { cell: cell.x + "," + cell.y } });
setSelectedCell(cell);
setSidebarOpen(true);
selectMapCell(cell);
},
[setSelectedCell, selectMapCell, router]
);
const clearSelectedCell = useCallback(() => {
setSelectedCell(null);
if (map.current) map.current.removeFeatureState({ source: "grid-source" });
if (map.current) {
map.current.removeFeatureState({ source: "selected-cell-source" });
// map.current.removeFeatureState({ source: "conflicted-cell-source" });
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
}, [map]);
const clearSelectedCells = useCallback(() => {
setSelectedCells(null);
dispatch(setSelectedFetchedPlugin(undefined));
if (map.current) {
map.current.removeFeatureState({ source: "selected-cells-source" });
map.current.removeFeatureState({ source: "conflicted-cell-source" });
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
}, [map, dispatch]);
const clearSelectedMod = useCallback(() => {
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
}, [map]);
const setSidebarOpenWithResize = useCallback(
(open) => {
setSidebarOpen(open);
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
},
[map]
);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
if (router.query.cell && typeof router.query.cell === "string") {
const cellUrlParts = decodeURIComponent(router.query.cell).split(",");
const cell = {
x: parseInt(cellUrlParts[0]),
y: parseInt(cellUrlParts[1]),
};
if (
!selectedCell ||
selectedCell.x !== cell.x ||
selectedCell.y !== cell.y
) {
clearSelectedCells();
selectCell(cell);
}
} else if (router.query.mod && typeof router.query.mod === "string") {
if (selectedCells) {
clearSelectedCell();
setSidebarOpen(true);
selectCells(selectedCells, { fitCells: true });
} else {
// TODO: this is so spaghetti
clearSelectedCell();
}
} else if (router.query.plugin && typeof router.query.plugin === "string") {
clearSelectedCell();
setSidebarOpen(true);
if (parsedPlugins && parsedPlugins.length > 0 && pluginsPending === 0) {
const plugin = parsedPlugins.find(
(p) => p.hash === router.query.plugin
);
if (plugin && plugin.parsed) {
const cells = [];
const cellSet = new Set<number>();
for (const cell of plugin?.parsed?.cells) {
if (
cell.x !== undefined &&
cell.y !== undefined &&
cell.world_form_id === 60 &&
plugin.parsed.header.masters[0] === "Skyrim.esm" &&
cellSet.has(cell.x + cell.y * 1000) === false
) {
cells.push({ x: cell.x, y: cell.y });
cellSet.add(cell.x + cell.y * 1000);
}
}
selectCells(cells, { fitCells: true });
}
}
} else {
clearSelectedCells();
clearSelectedCell();
}
if (
pluginsPending === 0 &&
((parsedPlugins && parsedPlugins.length > 0) ||
fetchedPlugins.length > 0) &&
!router.query.mod &&
!router.query.plugin
) {
let cells = parsedPlugins.reduce(
(acc: { x: number; y: number }[], plugin: PluginFile) => {
if (plugin.enabled && plugin.parsed) {
const newCells = [...acc];
for (const cell of plugin.parsed.cells) {
if (
cell.x !== undefined &&
cell.y !== undefined &&
cell.world_form_id === 60 &&
plugin.parsed.header.masters[0] === "Skyrim.esm"
) {
newCells.push({ x: cell.x, y: cell.y });
}
}
return newCells;
}
return acc;
},
[]
);
cells = cells.concat(
fetchedPlugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.cells)
);
selectCells(cells);
}
}, [
selectedCell,
selectedCells,
router.query.cell,
router.query.mod,
router.query.plugin,
selectCell,
selectCells,
clearSelectedCell,
clearSelectedCells,
heatmapLoaded,
parsedPlugins,
pluginsPending,
fetchedPlugins,
]);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
if (
router.query.plugin &&
typeof router.query.plugin === "string" &&
selectedFetchedPlugin &&
selectedFetchedPlugin.cells
) {
const cells = [];
const cellSet = new Set<number>();
for (const cell of selectedFetchedPlugin.cells) {
if (
cell.x !== undefined &&
cell.y !== undefined &&
cellSet.has(cell.x + cell.y * 1000) === false
) {
cells.push({ x: cell.x, y: cell.y });
cellSet.add(cell.x + cell.y * 1000);
}
}
selectCells(cells);
}
}, [heatmapLoaded, selectedFetchedPlugin, selectCells, router.query.plugin]);
useEffect(() => {
if (!heatmapLoaded) return; // wait for all map layers to load
if (!router.query.mod || typeof router.query.mod !== "string") {
clearSelectedMod();
}
}, [router.query.mod, clearSelectedMod, heatmapLoaded]);
useEffect(() => {
if (map.current) return; // initialize map only once
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: {
version: 8,
sources: {
"raster-tiles": {
type: "raster",
tiles: ["https://tiles.modmapper.com/{z}/{x}/{y}.jpg"],
tileSize: 256,
attribution:
'Map tiles by <a href="https://en.uesp.net/wiki/Skyrim:Skyrim" target="_blank">UESP</a> • Mod data from <a href="https://nexusmods.com" target="_blank">Nexus Mods</a> • <a href="https://github.com/thallada/modmapper-web" target="_blank">GitHub</a> • <a href="https://www.nexusmods.com/users/512579" target="_blank">Donate ❤️</a>',
},
},
layers: [
{
id: "simple-tiles",
type: "raster",
source: "raster-tiles",
},
],
glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",
},
center: [0, 0],
zoom: 0,
minZoom: 0,
maxZoom: 8,
maxBounds: [
[-180, -85.051129],
[180, 85.051129],
],
});
map.current.on("load", () => {
setMapLoaded(true);
});
}, [setMapLoaded]);
useEffect(() => {
if (!cellsData || !router.isReady || !mapLoaded) return; // wait for map to initialize and data to load
if (map.current.getSource("graticule")) return; // don't initialize twice
const zoom = map.current.getZoom();
const viewportNW = map.current.project([-180, 85.051129]);
const cellSize = Math.pow(2, zoom + 2);
const graticule: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
let lng = map.current.unproject([x * cellSize + viewportNW.x, -90]).lng;
graticule.features.push({
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[lng, -90],
[lng, 90],
],
},
properties: { value: x },
});
}
for (let y = 0; y < 128; y += 1) {
let lat = map.current.unproject([-180, y * cellSize + viewportNW.y]).lat;
graticule.features.push({
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[-180, lat],
[180, lat],
],
},
properties: { value: y },
});
}
map.current.addSource("graticule", {
type: "geojson",
data: graticule,
});
map.current.addLayer({
id: "graticule",
type: "line",
source: "graticule",
});
const gridLabelPoints: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
for (let y = 0; y < 128; y += 1) {
let nw = map.current.unproject([
x * cellSize + viewportNW.x + cellSize / 32,
y * cellSize + viewportNW.y + cellSize / 32,
]);
gridLabelPoints.features.push({
type: "Feature",
geometry: {
type: "Point",
coordinates: [nw.lng, nw.lat],
},
properties: {
label: `${x - 57}, ${50 - y}`,
},
});
}
}
map.current.addSource("grid-labels-source", {
type: "geojson",
data: gridLabelPoints,
});
map.current.addLayer({
id: "grid-labels-layer",
type: "symbol",
source: "grid-labels-source",
layout: {
"text-field": ["get", "label"],
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
"text-offset": [0, 0],
"text-anchor": "top-left",
"text-rotation-alignment": "map",
},
paint: {
"text-halo-width": 1,
"text-halo-blur": 3,
"text-halo-color": "rgba(255,255,255,0.8)",
},
minzoom: 4,
});
const grid: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
for (let y = 0; y < 128; y += 1) {
let nw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y,
]);
let ne = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y,
]);
let se = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y + cellSize,
]);
let sw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
const editCount = cellsData.data[`${x - 57},${50 - y}`];
grid.features.push({
type: "Feature",
id: x * 1000 + y,
geometry: {
type: "Polygon",
coordinates: [
[
[nw.lng, nw.lat],
[ne.lng, ne.lat],
[se.lng, se.lat],
[sw.lng, sw.lat],
[nw.lng, nw.lat],
],
],
},
properties: {
x: x,
y: y,
cellX: x - 57,
cellY: 50 - y,
label: `${x - 57}, ${50 - y}`,
color: editCount ? colorGradient.getColor(editCount) : "#888888",
opacity: editCount ? Math.min((editCount / 150) * 0.25, 0.5) : 0,
},
});
}
}
map.current.addSource("grid-source", {
type: "geojson",
data: grid,
});
map.current.addLayer(
{
id: "grid-layer",
type: "fill",
source: "grid-source",
paint: {
"fill-opacity": 0,
},
},
"grid-labels-layer"
);
map.current.addLayer(
{
id: "heatmap-layer",
type: "fill",
source: "grid-source",
paint: {
"fill-color": ["get", "color"],
"fill-opacity": ["get", "opacity"],
"fill-outline-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"white",
"transparent",
],
},
},
"grid-labels-layer"
);
const selectedCellLines: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
for (let y = 0; y < 128; y += 1) {
let nw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y,
]);
let ne = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y,
]);
let se = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y + cellSize,
]);
let sw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
selectedCellLines.features.push({
type: "Feature",
id: x * 1000 + y,
geometry: {
type: "LineString",
coordinates: [
[nw.lng, nw.lat],
[ne.lng, ne.lat],
[se.lng, se.lat],
[sw.lng, sw.lat],
[nw.lng, nw.lat],
[ne.lng, ne.lat],
],
},
properties: { x: x, y: y },
});
}
}
map.current.addSource("selected-cells-source", {
type: "geojson",
data: selectedCellLines,
});
map.current.addLayer({
id: "selected-cells-layer",
type: "line",
source: "selected-cells-source",
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "modSelected"], false],
"purple",
"transparent",
],
"line-width": [
"case",
["boolean", ["feature-state", "modSelected"], false],
4,
3,
],
},
layout: {
"line-join": "round",
},
});
const conflictedCellLines: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [],
};
for (let x = 0; x < 128; x += 1) {
for (let y = 0; y < 128; y += 1) {
let nw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y,
]);
let ne = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y,
]);
let se = map.current.unproject([
x * cellSize + viewportNW.x + cellSize,
y * cellSize + viewportNW.y + cellSize,
]);
let sw = map.current.unproject([
x * cellSize + viewportNW.x,
y * cellSize + viewportNW.y + cellSize,
]);
conflictedCellLines.features.push({
type: "Feature",
id: x * 1000 + y,
geometry: {
type: "LineString",
coordinates: [
[nw.lng, nw.lat],
[ne.lng, ne.lat],
[se.lng, se.lat],
[sw.lng, sw.lat],
[nw.lng, nw.lat],
[ne.lng, ne.lat],
],
},
properties: { x: x, y: y },
});
}
}
map.current.addSource("conflicted-cell-source", {
type: "geojson",
data: conflictedCellLines,
});
map.current.addLayer({
id: "conflicted-cell-layer",
type: "line",
source: "conflicted-cell-source",
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "conflicted"], false],
"red",
"transparent",
],
"line-width": 4,
},
layout: {
"line-join": "round",
},
});
map.current.addSource("selected-cell-source", {
type: "geojson",
data: selectedCellLines,
});
map.current.addLayer({
id: "selected-cell-layer",
type: "line",
source: "selected-cell-source",
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "cellSelected"], false],
"blue",
"transparent",
],
"line-width": 3,
},
layout: {
"line-join": "round",
},
});
const fullscreenControl = new mapboxgl.FullscreenControl();
(fullscreenControl as unknown as { _container: HTMLElement })._container =
mapWrapper.current;
map.current.addControl(fullscreenControl);
map.current.addControl(new mapboxgl.NavigationControl());
map.current.on("click", "grid-layer", (e) => {
const features = e.features;
if (features && features[0]) {
const cell = {
x: features[0].properties!.cellX,
y: features[0].properties!.cellY,
};
router.push({ query: { cell: cell.x + "," + cell.y } });
}
});
setHeatmapLoaded(true);
}, [cellsData, mapLoaded, router, setHeatmapLoaded]);
useEffect(() => {
if (countsData) {
const newCounts: Record<number, [number, number, number]> = {};
for (const line of countsData.split("\n")) {
const nums = line.split(",").map((count) => parseInt(count, 10));
newCounts[nums[0]] = [nums[1], nums[2], nums[3]];
}
setCounts(newCounts);
}
}, [setCounts, countsData]);
return (
<>
<div
className={`${styles["map-wrapper"]} ${
sidebarOpen ? styles["map-wrapper-sidebar-open"] : ""
}`}
ref={mapWrapper}
>
<div ref={mapContainer} className={styles["map-container"]}>
<SearchProvider>
<Sidebar
selectedCell={selectedCell}
clearSelectedCell={() => router.push({ query: {} })}
setSelectedCells={setSelectedCells}
counts={counts}
countsError={countsError}
open={sidebarOpen}
setOpen={setSidebarOpenWithResize}
lastModified={cellsData && cellsData.lastModified}
onSelectFile={(selectedFile) => {
const { plugin, ...withoutPlugin } = router.query;
if (selectedFile) {
router.push({
query: { ...withoutPlugin, file: selectedFile },
});
} else {
const { file, ...withoutFile } = withoutPlugin;
router.push({ query: { ...withoutFile } });
}
}}
onSelectPlugin={(selectedPlugin) => {
if (selectedPlugin) {
router.push({
query: { ...router.query, plugin: selectedPlugin },
});
} else {
const { plugin, ...withoutPlugin } = router.query;
router.push({ query: { ...withoutPlugin } });
}
}}
/>
<ToggleLayersControl map={map} />
<SearchBar
counts={counts}
sidebarOpen={sidebarOpen}
placeholder="Search mods or cells…"
onSelectResult={(selectedItem) => {
if (!selectedItem) return;
if (
selectedItem.x !== undefined &&
selectedItem.y !== undefined
) {
router.push({
query: { cell: `${selectedItem.x},${selectedItem.y}` },
});
} else {
router.push({ query: { mod: selectedItem.id } });
}
}}
includeCells
fixed
/>
</SearchProvider>
</div>
</div>
</>
);
};
export default Map;