Fetch & display data for selected cell in sidebar
This commit is contained in:
parent
8d94b68332
commit
a1f2cc830a
78
components/CellData.tsx
Normal file
78
components/CellData.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useSWRImmutable from 'swr/immutable';
|
||||||
|
|
||||||
|
import styles from "../styles/CellData.module.css";
|
||||||
|
import CellModList from "./CellModList";
|
||||||
|
|
||||||
|
export interface Mod {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nexus_mod_id: number;
|
||||||
|
author_name: string;
|
||||||
|
author_id: number;
|
||||||
|
category_name: string,
|
||||||
|
category_id: number,
|
||||||
|
description: string,
|
||||||
|
thumbnail_link: string,
|
||||||
|
game_id: number,
|
||||||
|
updated_at: string,
|
||||||
|
created_at: string,
|
||||||
|
last_update_at: string;
|
||||||
|
first_upload_at: string;
|
||||||
|
last_updated_files_at: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
form_id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
is_persistent: boolean;
|
||||||
|
mods_count: number;
|
||||||
|
files_count: number;
|
||||||
|
plugins_count: number;
|
||||||
|
mods: Mod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonFetcher = async (url: string): Promise<Cell | null> => {
|
||||||
|
const res = await fetch(url);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const error = new Error('An error occurred while fetching the data.')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedCell: [number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CellData: React.FC<Props> = ({ selectedCell }) => {
|
||||||
|
const { data, error } = useSWRImmutable(`https://cells.modmapper.com/${selectedCell[0]}/${selectedCell[1]}.json`, jsonFetcher);
|
||||||
|
|
||||||
|
if (error && error.status === 404) {
|
||||||
|
return <div>Cell has no mod edits.</div>;
|
||||||
|
} else if (error) {
|
||||||
|
console.log(error);
|
||||||
|
return <div>{`Error loading cell data: ${error.message}`}</div>;
|
||||||
|
}
|
||||||
|
if (data === undefined) return <div>Loading...</div>;
|
||||||
|
if (data === null) return <div>Cell has no edits.</div>;
|
||||||
|
|
||||||
|
return selectedCell && (
|
||||||
|
<>
|
||||||
|
<ul className={styles["cell-data-list"]}>
|
||||||
|
<li><strong>Form ID:</strong> <span>{data.form_id}</span></li>
|
||||||
|
<li><strong>Mods that edit:</strong> <span>{data.mods_count}</span></li>
|
||||||
|
<li><strong>Files that edit:</strong> <span>{data.files_count}</span></li>
|
||||||
|
<li><strong>Plugins that edit:</strong> <span>{data.plugins_count}</span></li>
|
||||||
|
</ul>
|
||||||
|
<CellModList mods={data.mods} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CellData;
|
111
components/CellModList.tsx
Normal file
111
components/CellModList.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { format } from "date-fns";
|
||||||
|
import React from "react";
|
||||||
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
|
import styles from "../styles/CellModList.module.css";
|
||||||
|
import type { Mod } from "./CellData";
|
||||||
|
|
||||||
|
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
||||||
|
const LIVE_DOWNLOAD_COUNTS_URL =
|
||||||
|
"https://staticstats.nexusmods.com/live_download_counts/mods/1704.csv";
|
||||||
|
|
||||||
|
const csvFetcher = (url: string) => fetch(url).then((res) => res.text());
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mods: Mod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModWithCounts = Mod & {
|
||||||
|
total_downloads: number;
|
||||||
|
unique_downloads: number;
|
||||||
|
views: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CellModList: React.FC<Props> = ({ mods }) => {
|
||||||
|
// The live download counts are not really immutable, but I'd still rather load them once per session
|
||||||
|
const { data, error } = useSWRImmutable(LIVE_DOWNLOAD_COUNTS_URL, csvFetcher);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return <div>{`Error loading live download counts: ${error.message}`}</div>;
|
||||||
|
if (!data) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
const counts = data
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.split(",").map((count) => parseInt(count, 10)));
|
||||||
|
console.log(counts);
|
||||||
|
|
||||||
|
const modsWithCounts: ModWithCounts[] = mods.map((mod) => {
|
||||||
|
const modCounts = counts.find((count) => count[0] === mod.nexus_mod_id);
|
||||||
|
console.log(mod.nexus_mod_id, modCounts);
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
total_downloads: modCounts ? modCounts[1] : 0,
|
||||||
|
unique_downloads: modCounts ? modCounts[2] : 0,
|
||||||
|
views: modCounts ? modCounts[3] : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let numberFmt = new Intl.NumberFormat("en-US");
|
||||||
|
|
||||||
|
return (
|
||||||
|
mods && (
|
||||||
|
<>
|
||||||
|
<h2>Mods</h2>
|
||||||
|
<ul className={styles["mod-list"]}>
|
||||||
|
{modsWithCounts
|
||||||
|
.sort((a, b) => b.unique_downloads - a.unique_downloads)
|
||||||
|
.map((mod) => (
|
||||||
|
<li key={mod.id} className={styles["mod-list-item"]}>
|
||||||
|
<div className={styles["mod-title"]}>
|
||||||
|
<strong>
|
||||||
|
<a
|
||||||
|
href={`${NEXUS_MODS_URL}/mods/${mod.nexus_mod_id}`}
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
{mod.name}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Category: </strong>
|
||||||
|
<a
|
||||||
|
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
{mod.category_name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Author: </strong>
|
||||||
|
<a
|
||||||
|
href={`${NEXUS_MODS_URL}/users/${mod.author_id}`}
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
{mod.author_name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Uploaded:</strong>{" "}
|
||||||
|
{format(new Date(mod.first_upload_at), "d MMM y")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Last Update:</strong>{" "}
|
||||||
|
{format(new Date(mod.last_update_at), "d MMM y")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Total Downloads:</strong>{" "}
|
||||||
|
{numberFmt.format(mod.total_downloads)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Unique Downloads:</strong>{" "}
|
||||||
|
{numberFmt.format(mod.unique_downloads)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CellModList;
|
@ -1,18 +1,26 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
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 styles from "../styles/Map.module.css";
|
import styles from "../styles/Map.module.css";
|
||||||
import cellModEdits from "../data/cellModEditCounts.json";
|
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import ToggleLayersControl from "./ToggleLayersControl";
|
import ToggleLayersControl from "./ToggleLayersControl";
|
||||||
|
|
||||||
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
|
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
|
||||||
|
|
||||||
const colorGradient = new Gradient();
|
const colorGradient = new Gradient();
|
||||||
colorGradient.setGradient("#0000FF", "#00FF00", "#FFFF00", "#FFA500", "#FF0000");
|
colorGradient.setGradient(
|
||||||
|
"#0000FF",
|
||||||
|
"#00FF00",
|
||||||
|
"#FFFF00",
|
||||||
|
"#FFA500",
|
||||||
|
"#FF0000"
|
||||||
|
);
|
||||||
colorGradient.setMidpoint(360);
|
colorGradient.setMidpoint(360);
|
||||||
|
|
||||||
|
const jsonFetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
const Map: React.FC = () => {
|
const Map: React.FC = () => {
|
||||||
const mapContainer = useRef<HTMLDivElement | null>(
|
const mapContainer = useRef<HTMLDivElement | null>(
|
||||||
null
|
null
|
||||||
@ -20,7 +28,14 @@ const Map: React.FC = () => {
|
|||||||
const map = useRef<mapboxgl.Map | null>(
|
const map = useRef<mapboxgl.Map | null>(
|
||||||
null
|
null
|
||||||
) as React.MutableRefObject<mapboxgl.Map>;
|
) as React.MutableRefObject<mapboxgl.Map>;
|
||||||
const [selectedCell, setSelectedCell] = useState<[number, number] | null>(null);
|
const [selectedCell, setSelectedCell] = useState<[number, number] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error } = useSWRImmutable(
|
||||||
|
"https://cells.modmapper.com/edits.json",
|
||||||
|
jsonFetcher
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (map.current) return; // initialize map only once
|
if (map.current) return; // initialize map only once
|
||||||
@ -50,16 +65,19 @@ const Map: React.FC = () => {
|
|||||||
zoom: 0,
|
zoom: 0,
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
maxZoom: 8,
|
maxZoom: 8,
|
||||||
maxBounds: [[-180, -85.051129], [180, 85.051129]]
|
maxBounds: [
|
||||||
|
[-180, -85.051129],
|
||||||
|
[180, 85.051129],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map.current) return; // wait for map to initialize
|
if (!map.current || !data) return; // wait for map to initialize and data to load
|
||||||
map.current.on("load", () => {
|
map.current.on("load", () => {
|
||||||
var zoom = map.current.getZoom();
|
const zoom = map.current.getZoom();
|
||||||
var viewportNW = map.current.project([-180, 85.051129]);
|
const viewportNW = map.current.project([-180, 85.051129]);
|
||||||
var cellSize = Math.pow(2, zoom + 2);
|
const cellSize = Math.pow(2, zoom + 2);
|
||||||
|
|
||||||
const graticule: GeoJSON.FeatureCollection<
|
const graticule: GeoJSON.FeatureCollection<
|
||||||
GeoJSON.Geometry,
|
GeoJSON.Geometry,
|
||||||
@ -141,26 +159,24 @@ const Map: React.FC = () => {
|
|||||||
data: gridLabelPoints,
|
data: gridLabelPoints,
|
||||||
});
|
});
|
||||||
|
|
||||||
map.current.addLayer(
|
map.current.addLayer({
|
||||||
{
|
id: "grid-labels-layer",
|
||||||
id: "grid-labels-layer",
|
type: "symbol",
|
||||||
type: "symbol",
|
source: "grid-labels-source",
|
||||||
source: "grid-labels-source",
|
layout: {
|
||||||
layout: {
|
"text-field": ["get", "label"],
|
||||||
"text-field": ["get", "label"],
|
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
|
||||||
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
|
"text-offset": [0, 0],
|
||||||
"text-offset": [0, 0],
|
"text-anchor": "top-left",
|
||||||
"text-anchor": "top-left",
|
"text-rotation-alignment": "map",
|
||||||
"text-rotation-alignment": "map",
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
"text-halo-width": 1,
|
|
||||||
"text-halo-blur": 3,
|
|
||||||
"text-halo-color": "rgba(255,255,255,0.8)",
|
|
||||||
},
|
|
||||||
minzoom: 4,
|
|
||||||
},
|
},
|
||||||
);
|
paint: {
|
||||||
|
"text-halo-width": 1,
|
||||||
|
"text-halo-blur": 3,
|
||||||
|
"text-halo-color": "rgba(255,255,255,0.8)",
|
||||||
|
},
|
||||||
|
minzoom: 4,
|
||||||
|
});
|
||||||
|
|
||||||
const grid: GeoJSON.FeatureCollection<
|
const grid: GeoJSON.FeatureCollection<
|
||||||
GeoJSON.Geometry,
|
GeoJSON.Geometry,
|
||||||
@ -187,7 +203,7 @@ const Map: React.FC = () => {
|
|||||||
x * cellSize + viewportNW.x,
|
x * cellSize + viewportNW.x,
|
||||||
y * cellSize + viewportNW.y + cellSize,
|
y * cellSize + viewportNW.y + cellSize,
|
||||||
]);
|
]);
|
||||||
const editCount = (cellModEdits as Record<string, number>)[
|
const editCount = (data as Record<string, number>)[
|
||||||
`${x - 57},${50 - y}`
|
`${x - 57},${50 - y}`
|
||||||
];
|
];
|
||||||
grid.features.push({
|
grid.features.push({
|
||||||
@ -248,7 +264,7 @@ const Map: React.FC = () => {
|
|||||||
["boolean", ["feature-state", "selected"], false],
|
["boolean", ["feature-state", "selected"], false],
|
||||||
"white",
|
"white",
|
||||||
"transparent",
|
"transparent",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"grid-labels-layer"
|
"grid-labels-layer"
|
||||||
@ -260,14 +276,20 @@ const Map: React.FC = () => {
|
|||||||
|
|
||||||
map.current.on("click", "grid-layer", (e) => {
|
map.current.on("click", "grid-layer", (e) => {
|
||||||
if (e.features && e.features[0]) {
|
if (e.features && e.features[0]) {
|
||||||
const cell: [number, number] = [e.features[0].properties!.cellX, e.features[0].properties!.cellY];
|
const cell: [number, number] = [
|
||||||
|
e.features[0].properties!.cellX,
|
||||||
|
e.features[0].properties!.cellY,
|
||||||
|
];
|
||||||
map.current.removeFeatureState({ source: "grid-source" });
|
map.current.removeFeatureState({ source: "grid-source" });
|
||||||
map.current.setFeatureState({
|
map.current.setFeatureState(
|
||||||
source: "grid-source",
|
{
|
||||||
id: e.features[0].id,
|
source: "grid-source",
|
||||||
}, {
|
id: e.features[0].id,
|
||||||
selected: true
|
},
|
||||||
});
|
{
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
setSelectedCell(cell);
|
setSelectedCell(cell);
|
||||||
map.current.resize();
|
map.current.resize();
|
||||||
|
|
||||||
@ -311,7 +333,7 @@ const Map: React.FC = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
properties: { x: x, y: y },
|
properties: { x: x, y: y },
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -336,12 +358,20 @@ const Map: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [setSelectedCell]);
|
}, [setSelectedCell, data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar selectedCell={selectedCell} setSelectedCell={setSelectedCell} map={map} />
|
<Sidebar
|
||||||
<div className={`${styles["map-wrapper"]} ${selectedCell ? styles["map-wrapper-sidebar-open"] : ""}`}>
|
selectedCell={selectedCell}
|
||||||
|
setSelectedCell={setSelectedCell}
|
||||||
|
map={map}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${styles["map-wrapper"]} ${
|
||||||
|
selectedCell ? styles["map-wrapper-sidebar-open"] : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div ref={mapContainer} className={styles["map-container"]} />
|
<div ref={mapContainer} className={styles["map-container"]} />
|
||||||
<ToggleLayersControl map={map} />
|
<ToggleLayersControl map={map} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import CellData from "./CellData";
|
||||||
import styles from "../styles/Sidebar.module.css";
|
import styles from "../styles/Sidebar.module.css";
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
form_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedCell: [number, number] | null;
|
selectedCell: [number, number] | null;
|
||||||
setSelectedCell: (cell: [number, number] | null) => void;
|
setSelectedCell: (cell: [number, number] | null) => void;
|
||||||
@ -18,15 +25,24 @@ const Sidebar: React.FC<Props> = ({ selectedCell, setSelectedCell, map }) => {
|
|||||||
if (map.current && map.current.getSource("selected-cell-source")) {
|
if (map.current && map.current.getSource("selected-cell-source")) {
|
||||||
map.current.removeSource("selected-cell-source");
|
map.current.removeSource("selected-cell-source");
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => { if (map.current) map.current.resize() });
|
requestAnimationFrame(() => {
|
||||||
}
|
if (map.current) map.current.resize();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return selectedCell && (
|
return (
|
||||||
<div className={styles.sidebar}>
|
selectedCell && (
|
||||||
<button className={styles.close} onClick={onClose}>✖</button>
|
<div className={styles.sidebar}>
|
||||||
<h1>Cell {selectedCell[0]}, {selectedCell[1]}</h1>
|
<button className={styles.close} onClick={onClose}>
|
||||||
</div>
|
✖
|
||||||
|
</button>
|
||||||
|
<h1>
|
||||||
|
Cell {selectedCell[0]}, {selectedCell[1]}
|
||||||
|
</h1>
|
||||||
|
{selectedCell && <CellData selectedCell={selectedCell} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
5702
package-lock.json
generated
5702
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,11 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"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",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2"
|
"react-dom": "17.0.2",
|
||||||
|
"swr": "^1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "17.0.8",
|
"@types/node": "17.0.8",
|
||||||
|
4
styles/CellData.module.css
Normal file
4
styles/CellData.module.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.cell-data-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
16
styles/CellModList.module.css
Normal file
16
styles/CellModList.module.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.mod-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-list-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.link {
|
||||||
|
color: #72030a;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user