Add search, query url based state

Still need to wire up the mod and cell data to the search.
This commit is contained in:
Tyler Hallada 2022-01-24 00:59:36 -05:00
parent dbec8def39
commit b263c6b0cb
9 changed files with 595 additions and 342 deletions

View File

@ -47,12 +47,12 @@ const jsonFetcher = async (url: string): Promise<Cell | null> => {
}; };
type Props = { type Props = {
selectedCell: [number, number]; selectedCell: { x: number; y: number };
}; };
const CellData: React.FC<Props> = ({ selectedCell }) => { const CellData: React.FC<Props> = ({ selectedCell }) => {
const { data, error } = useSWRImmutable( const { data, error } = useSWRImmutable(
`https://cells.modmapper.com/${selectedCell[0]}/${selectedCell[1]}.json`, `https://cells.modmapper.com/${selectedCell.x}/${selectedCell.y}.json`,
jsonFetcher jsonFetcher
); );

View File

@ -1,4 +1,5 @@
import React, { useRef, useEffect, useState } from "react"; import React, { useCallback, useRef, useEffect, useState } from "react";
import { useRouter } from "next/router";
import Gradient from "javascript-color-gradient"; import Gradient from "javascript-color-gradient";
import mapboxgl from "mapbox-gl"; import mapboxgl from "mapbox-gl";
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from "swr/immutable";
@ -6,6 +7,7 @@ import useSWRImmutable from "swr/immutable";
import styles from "../styles/Map.module.css"; import styles from "../styles/Map.module.css";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import ToggleLayersControl from "./ToggleLayersControl"; import ToggleLayersControl from "./ToggleLayersControl";
import SearchBar from "./SearchBar";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? ""; mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
@ -22,6 +24,7 @@ colorGradient.setMidpoint(360);
const jsonFetcher = (url: string) => fetch(url).then((res) => res.json()); const jsonFetcher = (url: string) => fetch(url).then((res) => res.json());
const Map: React.FC = () => { const Map: React.FC = () => {
const router = useRouter();
const mapContainer = useRef<HTMLDivElement | null>( const mapContainer = useRef<HTMLDivElement | null>(
null null
) as React.MutableRefObject<HTMLDivElement>; ) as React.MutableRefObject<HTMLDivElement>;
@ -31,15 +34,160 @@ const Map: React.FC = () => {
const mapWrapper = useRef<HTMLDivElement | null>( const mapWrapper = useRef<HTMLDivElement | null>(
null null
) as React.MutableRefObject<HTMLDivElement>; ) as React.MutableRefObject<HTMLDivElement>;
const [selectedCell, setSelectedCell] = useState<[number, number] | null>(
null const [mapLoaded, setMapLoaded] = useState<boolean>(false);
); const [heatmapLoaded, setHeatmapLoaded] = useState<boolean>(false);
const [selectedCell, setSelectedCell] = useState<{
x: number;
y: number;
} | null>(null);
const sidebarOpen = selectedCell !== null || router.query.mod !== undefined;
const { data, error } = useSWRImmutable( const { data, error } = useSWRImmutable(
"https://cells.modmapper.com/edits.json", "https://cells.modmapper.com/edits.json",
jsonFetcher jsonFetcher
); );
const selectMapCell = useCallback(
(cell: { x: number; y: number }) => {
if (!map.current) return;
if (map.current && !map.current.getSource("grid-source")) return;
map.current.setFeatureState(
{
source: "grid-source",
id: (cell.x + 57) * 100 + 50 - cell.y,
},
{
selected: true,
}
);
requestAnimationFrame(() => map.current && map.current.resize());
var zoom = map.current.getZoom();
var viewportNW = map.current.project([-180, 85.051129]);
var 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 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 selectedCellLines: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
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 },
},
],
};
if (map.current.getLayer("selected-cell-layer")) {
map.current.removeLayer("selected-cell-layer");
}
if (map.current.getSource("selected-cell-source")) {
map.current.removeSource("selected-cell-source");
}
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": "blue",
"line-width": 3,
},
});
const bounds = map.current.getBounds();
if (!bounds.contains(nw) || !bounds.contains(se)) {
map.current.panTo(nw);
}
},
[map]
);
const selectCell = useCallback(
(cell) => {
router.push({ query: { cell: cell.x + "," + cell.y } });
setSelectedCell(cell);
selectMapCell(cell);
},
[setSelectedCell, selectMapCell, router]
);
const clearSelectedCell = useCallback(() => {
setSelectedCell(null);
router.push({ query: {} });
if (map.current) map.current.removeFeatureState({ source: "grid-source" });
if (map.current && map.current.getLayer("selected-cell-layer")) {
map.current.removeLayer("selected-cell-layer");
}
if (map.current && map.current.getSource("selected-cell-source")) {
map.current.removeSource("selected-cell-source");
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
}, [map, router]);
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
) {
selectCell(cell);
}
} else {
if (selectedCell) {
clearSelectedCell();
}
}
}, [
selectedCell,
router.query.cell,
selectCell,
clearSelectedCell,
heatmapLoaded,
]);
useEffect(() => { useEffect(() => {
if (map.current) return; // initialize map only once if (map.current) return; // initialize map only once
map.current = new mapboxgl.Map({ map.current = new mapboxgl.Map({
@ -73,11 +221,15 @@ const Map: React.FC = () => {
[180, 85.051129], [180, 85.051129],
], ],
}); });
map.current.on("load", () => {
setMapLoaded(true);
}); });
}, [setMapLoaded]);
useEffect(() => { useEffect(() => {
if (!data) return; // wait for map to initialize and data to load if (!data || !router.isReady || !mapLoaded) return; // wait for map to initialize and data to load
map.current.on("load", () => { if (map.current.getSource("graticule")) return; // don't initialize twice
const zoom = map.current.getZoom(); const zoom = map.current.getZoom();
const viewportNW = map.current.project([-180, 85.051129]); const viewportNW = map.current.project([-180, 85.051129]);
const cellSize = Math.pow(2, zoom + 2); const cellSize = Math.pow(2, zoom + 2);
@ -104,10 +256,7 @@ const Map: React.FC = () => {
}); });
} }
for (let y = 0; y < 128; y += 1) { for (let y = 0; y < 128; y += 1) {
let lat = map.current.unproject([ let lat = map.current.unproject([-180, y * cellSize + viewportNW.y]).lat;
-180,
y * cellSize + viewportNW.y,
]).lat;
graticule.features.push({ graticule.features.push({
type: "Feature", type: "Feature",
geometry: { geometry: {
@ -273,111 +422,23 @@ const Map: React.FC = () => {
"grid-labels-layer" "grid-labels-layer"
); );
const fullscreenControl = new mapboxgl.FullscreenControl(); const fullscreenControl = new mapboxgl.FullscreenControl();
console.log(
(fullscreenControl as unknown as { _container: HTMLElement })._container
);
(fullscreenControl as unknown as { _container: HTMLElement })._container = (fullscreenControl as unknown as { _container: HTMLElement })._container =
mapWrapper.current; mapWrapper.current;
console.log(
(fullscreenControl as unknown as { _container: HTMLElement })._container
);
map.current.addControl(fullscreenControl); map.current.addControl(fullscreenControl);
map.current.addControl(new mapboxgl.NavigationControl()); map.current.addControl(new mapboxgl.NavigationControl());
let singleClickTimeout: NodeJS.Timeout | null = null; let singleClickTimeout: NodeJS.Timeout | null = null;
map.current.on("click", "grid-layer", (e) => { map.current.on("click", "grid-layer", (e) => {
console.log("click");
const features = e.features; const features = e.features;
if (singleClickTimeout) return; if (singleClickTimeout) return;
singleClickTimeout = setTimeout(() => { singleClickTimeout = setTimeout(() => {
singleClickTimeout = null; singleClickTimeout = null;
if (features && features[0]) { if (features && features[0]) {
console.log("timeout"); const cell = {
const cell: [number, number] = [ x: features[0].properties!.cellX,
features[0].properties!.cellX, y: features[0].properties!.cellY,
features[0].properties!.cellY,
];
map.current.removeFeatureState({ source: "grid-source" });
map.current.setFeatureState(
{
source: "grid-source",
id: features[0].id,
},
{
selected: true,
}
);
setSelectedCell(cell);
map.current.resize();
var zoom = map.current.getZoom();
var viewportNW = map.current.project([-180, 85.051129]);
var cellSize = Math.pow(2, zoom + 2);
const x = features[0].properties!.x;
const y = features[0].properties!.y;
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 selectedCellLines: GeoJSON.FeatureCollection<
GeoJSON.Geometry,
GeoJSON.GeoJsonProperties
> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "LineString",
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 },
},
],
}; };
router.push({ query: { cell: cell.x + "," + cell.y } });
if (map.current.getLayer("selected-cell-layer")) {
map.current.removeLayer("selected-cell-layer");
}
if (map.current.getSource("selected-cell-source")) {
map.current.removeSource("selected-cell-source");
}
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": "blue",
"line-width": 3,
},
});
const bounds = map.current.getBounds();
if (!bounds.contains(nw) || !bounds.contains(se)) {
map.current.panTo(nw);
}
} }
}, 200); }, 200);
}); });
@ -387,27 +448,28 @@ const Map: React.FC = () => {
singleClickTimeout = null; singleClickTimeout = null;
}); });
map.current.on("idle", () => { setHeatmapLoaded(true);
map.current.resize(); }, [data, mapLoaded, router, setHeatmapLoaded]);
});
});
}, [setSelectedCell, data]);
return ( return (
<> <>
<div <div
className={`${styles["map-wrapper"]} ${ className={`${styles["map-wrapper"]} ${
selectedCell ? styles["map-wrapper-sidebar-open"] : "" sidebarOpen ? styles["map-wrapper-sidebar-open"] : ""
}`} }`}
ref={mapWrapper} ref={mapWrapper}
> >
<div ref={mapContainer} className={styles["map-container"]}> <div ref={mapContainer} className={styles["map-container"]}>
<Sidebar <Sidebar
selectedCell={selectedCell} selectedCell={selectedCell}
setSelectedCell={setSelectedCell} clearSelectedCell={() => router.push({ query: {} })}
map={map} map={map}
/> />
<ToggleLayersControl map={map} /> <ToggleLayersControl map={map} />
<SearchBar
map={map}
clearSelectedCell={() => router.push({ query: {} })}
/>
</div> </div>
</div> </div>
</> </>

113
components/SearchBar.tsx Normal file
View File

@ -0,0 +1,113 @@
import React, { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import type mapboxgl from "mapbox-gl";
import Fuse from "fuse.js";
import styles from "../styles/SearchBar.module.css";
type Props = {
clearSelectedCell: () => void;
map: React.MutableRefObject<mapboxgl.Map>;
};
interface Mod {
title: string;
nexus_mod_id: number;
}
interface SearchResult {
item: Mod;
refIndex: number;
}
const list: Mod[] = [
{ title: "Unofficial Skyrim Special Edition Patch", nexus_mod_id: 1 },
{ title: "Enhanced Lights and FX", nexus_mod_id: 2 },
{ title: "Majestic Mountains", nexus_mod_id: 3 },
];
const SearchBar: React.FC<Props> = ({ clearSelectedCell, map }) => {
const router = useRouter();
const fuse = new Fuse(list, { keys: ["title"] });
const searchInput = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState<string>("");
const [searchFocused, setSearchFocused] = useState<boolean>(false);
const [clickingResult, setClickingResult] = useState<boolean>(false);
const [results, setResults] = useState<SearchResult[]>([]);
useEffect(() => {
if (searchInput.current) {
if (
searchFocused &&
global.document.activeElement !== searchInput.current
) {
searchInput.current.focus();
} else if (
!searchFocused &&
global.document.activeElement === searchInput.current
) {
searchInput.current.blur();
}
}
}, [searchFocused]);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
const results: { item: Mod; refIndex: number }[] = fuse.search(
e.target.value
);
setResults(results);
};
const onChooseResult =
(item: Mod) =>
(e: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>) => {
clearSelectedCell();
router.push({ query: { mod: item.nexus_mod_id } });
setSearch("");
setResults([]);
setClickingResult(false);
setSearchFocused(false);
};
return (
<div
className={`${styles["search-bar"]} ${
searchFocused ? styles["search-bar-focused"] : ""
}`}
>
<input
type="text"
placeholder="Search mods or cells…"
onChange={onChange}
onFocus={() => setSearchFocused(true)}
onBlur={() => {
if (!clickingResult) {
setSearch("");
setResults([]);
setSearchFocused(false);
}
}}
value={search}
ref={searchInput}
/>
{results.length > 0 && (
<ul className={styles["search-results"]}>
{results.map((result) => (
<li
key={result.item.nexus_mod_id}
onClick={onChooseResult(result.item)}
onTouchStart={() => setClickingResult(true)}
onMouseDown={() => setClickingResult(true)}
>
{result.item.title}
</li>
))}
</ul>
)}
</div>
);
};
export default SearchBar;

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router";
import CellData from "./CellData"; import CellData from "./CellData";
import styles from "../styles/Sidebar.module.css"; import styles from "../styles/Sidebar.module.css";
@ -10,39 +11,42 @@ interface Cell {
} }
type Props = { type Props = {
selectedCell: [number, number] | null; selectedCell: { x: number; y: number } | null;
setSelectedCell: (cell: [number, number] | null) => void; clearSelectedCell: () => void;
map: React.MutableRefObject<mapboxgl.Map | null>; map: React.MutableRefObject<mapboxgl.Map | null>;
}; };
const Sidebar: React.FC<Props> = ({ selectedCell, setSelectedCell, map }) => { const Sidebar: React.FC<Props> = ({ selectedCell, clearSelectedCell, map }) => {
const router = useRouter();
const onClose = () => { const onClose = () => {
setSelectedCell(null); clearSelectedCell();
if (map.current) map.current.removeFeatureState({ source: "grid-source" });
if (map.current && map.current.getLayer("selected-cell-layer")) {
map.current.removeLayer("selected-cell-layer");
}
if (map.current && map.current.getSource("selected-cell-source")) {
map.current.removeSource("selected-cell-source");
}
requestAnimationFrame(() => {
if (map.current) map.current.resize();
});
}; };
if (selectedCell) {
return ( return (
selectedCell && (
<div className={styles.sidebar}> <div className={styles.sidebar}>
<button className={styles.close} onClick={onClose}> <button className={styles.close} onClick={onClose}>
</button> </button>
<h1> <h1>
Cell {selectedCell[0]}, {selectedCell[1]} Cell {selectedCell.x}, {selectedCell.y}
</h1> </h1>
{selectedCell && <CellData selectedCell={selectedCell} />} {selectedCell && <CellData selectedCell={selectedCell} />}
</div> </div>
)
); );
} else if (router.query.mod) {
return (
<div className={styles.sidebar}>
<button className={styles.close} onClick={onClose}>
</button>
<h1>Mod {router.query.mod}</h1>
</div>
);
} else {
return null;
}
}; };
export default Sidebar; export default Sidebar;

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import type mapboxgl from "mapbox-gl"; import type mapboxgl from "mapbox-gl";
import styles from "../styles/ToggleLayersControl.module.css"; import styles from "../styles/ToggleLayersControl.module.css";
@ -40,8 +41,15 @@ const ToggleLayersControl: React.FC<Props> = ({ map }) => {
} }
}, [map, labelsVisible]); }, [map, labelsVisible]);
return ( let controlContainer;
<div className="mapboxgl-ctrl-top-left"> if (global.document) {
controlContainer = global.document.querySelector(
".mapboxgl-control-container .mapboxgl-ctrl-top-left"
);
}
if (controlContainer) {
return ReactDOM.createPortal(
<div className="mapboxgl-ctrl mapboxgl-ctrl-group"> <div className="mapboxgl-ctrl mapboxgl-ctrl-group">
<button <button
type="button" type="button"
@ -73,9 +81,11 @@ const ToggleLayersControl: React.FC<Props> = ({ map }) => {
> >
<span className="mapboxgl-ctrl-icon" /> <span className="mapboxgl-ctrl-icon" />
</button> </button>
</div> </div>,
</div> controlContainer
); );
}
return null;
}; };
export default ToggleLayersControl; export default ToggleLayersControl;

14
package-lock.json generated
View File

@ -309,6 +309,15 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"@types/react-dom": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
"integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/scheduler": { "@types/scheduler": {
"version": "0.16.2", "version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@ -1201,6 +1210,11 @@
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
"dev": true "dev": true
}, },
"fuse.js": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg=="
},
"geojson-vt": { "geojson-vt": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",

View File

@ -11,6 +11,7 @@
"@types/javascript-color-gradient": "^1.3.0", "@types/javascript-color-gradient": "^1.3.0",
"@types/mapbox-gl": "^2.6.0", "@types/mapbox-gl": "^2.6.0",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"fuse.js": "^6.5.3",
"javascript-color-gradient": "^1.3.2", "javascript-color-gradient": "^1.3.2",
"mapbox-gl": "^2.6.1", "mapbox-gl": "^2.6.1",
"next": "12.0.8", "next": "12.0.8",
@ -21,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "17.0.8", "@types/node": "17.0.8",
"@types/react": "17.0.38", "@types/react": "17.0.38",
"@types/react-dom": "^17.0.11",
"eslint": "8.6.0", "eslint": "8.6.0",
"eslint-config-next": "12.0.8", "eslint-config-next": "12.0.8",
"typescript": "4.5.4" "typescript": "4.5.4"

View File

@ -0,0 +1,46 @@
.search-bar {
position: fixed;
top: 8px;
width: 150px;
left: calc(50% - 75px);
z-index: 2;
}
.search-bar.search-bar-focused {
width: max(40vw, 250px);
left: calc(50% - max(20vw, 125px));
}
.search-bar input {
width: 150px;
border-radius: 8px;
padding-left: 8px;
padding-right: 8px;
}
.search-bar.search-bar.search-bar-focused input {
width: max(40vw, 250px);
border-radius: 8px;
padding-left: 8px;
padding-right: 8px;
}
.search-results {
background-color: white;
margin-top: 4px;
padding: 4px;
list-style-type: none;
border-radius: 8px;
width: 100%;
}
.search-results li {
padding-top: 2px;
padding-bottom: 2px;
border-top: 1px solid #222222;
cursor: pointer;
}
.search-results li:first-child {
border-top: none;
}

View File

@ -20,6 +20,8 @@
left: 0; left: 0;
height: 45%; height: 45%;
width: 100%; width: 100%;
border-right: none;
border-top: 1px solid #222222;
} }
} }