Fetch & display data for selected cell in sidebar

This commit is contained in:
Tyler Hallada 2022-01-19 01:06:19 -05:00
parent 8d94b68332
commit a1f2cc830a
8 changed files with 343 additions and 5716 deletions

78
components/CellData.tsx Normal file
View 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
View 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:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${mod.category_id}`}
className={styles.link}
>
{mod.category_name}
</a>
</div>
<div>
<strong>Author:&nbsp;</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;

View File

@ -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>

View File

@ -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,14 +25,23 @@ 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>
)
); );
}; };

5702
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -0,0 +1,4 @@
.cell-data-list {
list-style-type: none;
padding: 0;
}

View 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;
}