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 Gradient from "javascript-color-gradient";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import styles from "../styles/Map.module.css";
|
||||
import cellModEdits from "../data/cellModEditCounts.json";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ToggleLayersControl from "./ToggleLayersControl";
|
||||
|
||||
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN ?? "";
|
||||
|
||||
const colorGradient = new Gradient();
|
||||
colorGradient.setGradient("#0000FF", "#00FF00", "#FFFF00", "#FFA500", "#FF0000");
|
||||
colorGradient.setGradient(
|
||||
"#0000FF",
|
||||
"#00FF00",
|
||||
"#FFFF00",
|
||||
"#FFA500",
|
||||
"#FF0000"
|
||||
);
|
||||
colorGradient.setMidpoint(360);
|
||||
|
||||
const jsonFetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
const Map: React.FC = () => {
|
||||
const mapContainer = useRef<HTMLDivElement | null>(
|
||||
null
|
||||
@ -20,7 +28,14 @@ const Map: React.FC = () => {
|
||||
const map = useRef<mapboxgl.Map | null>(
|
||||
null
|
||||
) 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(() => {
|
||||
if (map.current) return; // initialize map only once
|
||||
@ -50,16 +65,19 @@ const Map: React.FC = () => {
|
||||
zoom: 0,
|
||||
minZoom: 0,
|
||||
maxZoom: 8,
|
||||
maxBounds: [[-180, -85.051129], [180, 85.051129]]
|
||||
maxBounds: [
|
||||
[-180, -85.051129],
|
||||
[180, 85.051129],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
var zoom = map.current.getZoom();
|
||||
var viewportNW = map.current.project([-180, 85.051129]);
|
||||
var cellSize = Math.pow(2, zoom + 2);
|
||||
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,
|
||||
@ -141,26 +159,24 @@ const Map: React.FC = () => {
|
||||
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,
|
||||
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,
|
||||
@ -187,7 +203,7 @@ const Map: React.FC = () => {
|
||||
x * cellSize + viewportNW.x,
|
||||
y * cellSize + viewportNW.y + cellSize,
|
||||
]);
|
||||
const editCount = (cellModEdits as Record<string, number>)[
|
||||
const editCount = (data as Record<string, number>)[
|
||||
`${x - 57},${50 - y}`
|
||||
];
|
||||
grid.features.push({
|
||||
@ -248,7 +264,7 @@ const Map: React.FC = () => {
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"white",
|
||||
"transparent",
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
"grid-labels-layer"
|
||||
@ -260,14 +276,20 @@ const Map: React.FC = () => {
|
||||
|
||||
map.current.on("click", "grid-layer", (e) => {
|
||||
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.setFeatureState({
|
||||
source: "grid-source",
|
||||
id: e.features[0].id,
|
||||
}, {
|
||||
selected: true
|
||||
});
|
||||
map.current.setFeatureState(
|
||||
{
|
||||
source: "grid-source",
|
||||
id: e.features[0].id,
|
||||
},
|
||||
{
|
||||
selected: true,
|
||||
}
|
||||
);
|
||||
setSelectedCell(cell);
|
||||
map.current.resize();
|
||||
|
||||
@ -311,7 +333,7 @@ const Map: React.FC = () => {
|
||||
],
|
||||
},
|
||||
properties: { x: x, y: y },
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -336,12 +358,20 @@ const Map: React.FC = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [setSelectedCell]);
|
||||
}, [setSelectedCell, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar selectedCell={selectedCell} setSelectedCell={setSelectedCell} map={map} />
|
||||
<div className={`${styles["map-wrapper"]} ${selectedCell ? styles["map-wrapper-sidebar-open"] : ""}`}>
|
||||
<Sidebar
|
||||
selectedCell={selectedCell}
|
||||
setSelectedCell={setSelectedCell}
|
||||
map={map}
|
||||
/>
|
||||
<div
|
||||
className={`${styles["map-wrapper"]} ${
|
||||
selectedCell ? styles["map-wrapper-sidebar-open"] : ""
|
||||
}`}
|
||||
>
|
||||
<div ref={mapContainer} className={styles["map-container"]} />
|
||||
<ToggleLayersControl map={map} />
|
||||
</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";
|
||||
|
||||
interface Cell {
|
||||
x: number;
|
||||
y: number;
|
||||
form_id: number;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
selectedCell: [number, number] | null;
|
||||
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")) {
|
||||
map.current.removeSource("selected-cell-source");
|
||||
}
|
||||
requestAnimationFrame(() => { if (map.current) map.current.resize() });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (map.current) map.current.resize();
|
||||
});
|
||||
};
|
||||
|
||||
return selectedCell && (
|
||||
<div className={styles.sidebar}>
|
||||
<button className={styles.close} onClick={onClose}>✖</button>
|
||||
<h1>Cell {selectedCell[0]}, {selectedCell[1]}</h1>
|
||||
</div>
|
||||
return (
|
||||
selectedCell && (
|
||||
<div className={styles.sidebar}>
|
||||
<button className={styles.close} onClick={onClose}>
|
||||
✖
|
||||
</button>
|
||||
<h1>
|
||||
Cell {selectedCell[0]}, {selectedCell[1]}
|
||||
</h1>
|
||||
{selectedCell && <CellData selectedCell={selectedCell} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
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": {
|
||||
"@types/javascript-color-gradient": "^1.3.0",
|
||||
"@types/mapbox-gl": "^2.6.0",
|
||||
"date-fns": "^2.28.0",
|
||||
"javascript-color-gradient": "^1.3.2",
|
||||
"mapbox-gl": "^2.6.1",
|
||||
"next": "12.0.8",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
"react-dom": "17.0.2",
|
||||
"swr": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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