From e674ff96f282d44a99fb905ba7653f6935ff9ba7 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 18 Feb 2026 14:34:49 +0100 Subject: [PATCH 01/36] added auth.js+User.js+userRoutes.js and setup MongoDB+.env --- backend/middleware/auth.js | 26 ++++++++++ backend/models/User.js | 31 ++++++++++++ backend/routes/userRoutes.js | 98 ++++++++++++++++++++++++++++++++++++ backend/server.js | 14 +++++- frontend/src/App.jsx | 3 +- 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 backend/middleware/auth.js create mode 100644 backend/models/User.js create mode 100644 backend/routes/userRoutes.js diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 000000000..b1b4f26be --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,26 @@ +import { User } from "../models/User.js"; + +export const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization").replace("Bearer ", ""), + }); + + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + success: false, + message: "Unauthorized: Invalid or missing access token", + loggedOut: true, + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..2b2fe4e03 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,31 @@ +import mongoose, { Schema } from "mongoose"; +import crypto from "crypto"; + +const UserSchema = new Schema( + { + username: { + type: String, + required: true, + unique: true, + trim: true, + }, + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + }, + { timestamps: true }, +); + +export const User = mongoose.model("User", UserSchema); diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..dd3df6874 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,98 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +router.post("/signup", async (req, res) => { + try { + const { username, email, password } = req.body; + + if (!username || !email || !password) { + return res.status(400).json({ + success: false, + message: "Username, email, and password are required", + }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: "invalid email format", + }); + } + + const existingUser = await User.findOne({ email: email.toLowerCase() }); + + if (existingUser) { + return res.status(409).json({ + success: false, + message: "An error occurred when creating the user", + }); + } + + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({ + username, + email: email.toLowerCase(), + password: hashedPassword, + }); + + await user.save(); + + res.status(201).json({ + success: true, + message: "User created successfully", + response: { + username: user.username, + email: user.email, + userId: user._id, + accessToken: user.accessToken, + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + response: error, + }); + } +}); + +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: "Login successful", + response: { + username: user.username, + email: user.email, + userId: user._id, + accessToken: user.accessToken, + }, + }); + } else { + res.status(401).json({ + success: false, + message: "Invalid email or password", + response: null, + }); + } + } catch (error) { + res.status(400).json({ + success: false, + message: "Login failed", + response: error, + }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 070c87518..b1015bba9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,8 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import userRoutes from "./routes/userRoutes.js"; +import listEndPoints from "express-list-endpoints"; const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; mongoose.connect(mongoUrl); @@ -12,8 +14,16 @@ const app = express(); app.use(cors()); app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); +// routes +app.use("/users", userRoutes); + +const endpoints = listEndPoints(app); + +app.get("/", (_req, res) => { + res.json({ + message: "Hello History!", + endpoints, + }); }); // Start the server diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..2ecb44dee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,7 @@ export const App = () => { - return ( <> -

Welcome to Final Project!

+

Welcome to History!

); }; From 6ad1c63f3f94e13cef8be85cd300afe181184e9a Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 19 Feb 2026 12:00:06 +0100 Subject: [PATCH 02/36] Removed username auth, use email login, and verify MongoDB connection --- backend/models/User.js | 6 ------ backend/package.json | 8 ++++++-- backend/routes/userRoutes.js | 10 ++++------ backend/server.js | 13 ++++++++++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/backend/models/User.js b/backend/models/User.js index 2b2fe4e03..26b70117c 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -3,12 +3,6 @@ import crypto from "crypto"; const UserSchema = new Schema( { - username: { - type: String, - required: true, - unique: true, - trim: true, - }, email: { type: String, required: true, diff --git a/backend/package.json b/backend/package.json index 08f29f244..12695a06a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,7 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { "start": "babel-node server.js", @@ -12,9 +13,12 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.17.3", - "mongoose": "^8.4.0", + "express-list-endpoints": "^7.1.1", + "mongoose": "^8.23.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index dd3df6874..dd541abe9 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -6,12 +6,12 @@ const router = express.Router(); router.post("/signup", async (req, res) => { try { - const { username, email, password } = req.body; + const { email, password } = req.body; - if (!username || !email || !password) { + if (!email || !password) { return res.status(400).json({ success: false, - message: "Username, email, and password are required", + message: "Email, and password are required", }); } @@ -36,7 +36,6 @@ router.post("/signup", async (req, res) => { const salt = bcrypt.genSaltSync(); const hashedPassword = bcrypt.hashSync(password, salt); const user = new User({ - username, email: email.toLowerCase(), password: hashedPassword, }); @@ -47,9 +46,9 @@ router.post("/signup", async (req, res) => { success: true, message: "User created successfully", response: { - username: user.username, email: user.email, userId: user._id, + createdAt: user.createdAt, accessToken: user.accessToken, }, }); @@ -73,7 +72,6 @@ router.post("/login", async (req, res) => { success: true, message: "Login successful", response: { - username: user.username, email: user.email, userId: user._id, accessToken: user.accessToken, diff --git a/backend/server.js b/backend/server.js index b1015bba9..216fb383d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,12 +1,19 @@ +import "dotenv/config"; import express from "express"; import cors from "cors"; import mongoose from "mongoose"; import userRoutes from "./routes/userRoutes.js"; import listEndPoints from "express-list-endpoints"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +const mongoUrl = process.env.MONGO_URL; + +try { + await mongoose.connect(mongoUrl); + console.log("Connected to MongoDB"); +} catch (error) { + console.error("MongoDB connection error:", error); + process.exit(1); +} const port = process.env.PORT || 8080; const app = express(); From 81e188794d313781d12af399167d4b5b8d74afa8 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 19 Feb 2026 13:31:50 +0100 Subject: [PATCH 03/36] Configure frontend env variable and ignore build output --- .gitignore | 1 + frontend/constants.js | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 frontend/constants.js diff --git a/.gitignore b/.gitignore index 3d70248ba..635ca404d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .env.production.local build +frontend/dist npm-debug.log* yarn-debug.log* diff --git a/frontend/constants.js b/frontend/constants.js new file mode 100644 index 000000000..5a19ef92e --- /dev/null +++ b/frontend/constants.js @@ -0,0 +1,3 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +//export const API_BASE_URL = "https://historytimeline-backend.onrender.com"; From 6fe85f6e7a11a87332ca0c615834f5bb0a79881a Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 19 Feb 2026 14:06:05 +0100 Subject: [PATCH 04/36] Add Netlify config for Vite frontend --- netlify.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 netlify.toml diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..e69de29bb From 6a739744c519792fac40cf7987120037514cae1f Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 19 Feb 2026 14:07:46 +0100 Subject: [PATCH 05/36] "Add Netlify config for Vite frontend" --- netlify.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netlify.toml b/netlify.toml index e69de29bb..e3c4c4a99 100644 --- a/netlify.toml +++ b/netlify.toml @@ -0,0 +1,4 @@ +[build] + base = "frontend" + command = "npm run build" + publish = "dist" From dd1ad4ace627ccbcd95e4f3191dfe091eaf646e7 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 20 Feb 2026 11:21:52 +0100 Subject: [PATCH 06/36] feat(auth): add FormField and login and signup forms --- frontend/constants.js | 3 - frontend/src/App.jsx | 40 +++++++++- frontend/src/components/FormField.jsx | 27 +++++++ frontend/src/components/LoginForm.jsx | 84 ++++++++++++++++++++ frontend/src/components/SignupForm.jsx | 105 +++++++++++++++++++++++++ frontend/src/constants.js | 1 + 6 files changed, 253 insertions(+), 7 deletions(-) delete mode 100644 frontend/constants.js create mode 100644 frontend/src/components/FormField.jsx create mode 100644 frontend/src/components/LoginForm.jsx create mode 100644 frontend/src/components/SignupForm.jsx create mode 100644 frontend/src/constants.js diff --git a/frontend/constants.js b/frontend/constants.js deleted file mode 100644 index 5a19ef92e..000000000 --- a/frontend/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; - -//export const API_BASE_URL = "https://historytimeline-backend.onrender.com"; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2ecb44dee..9410ca0b7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,39 @@ -export const App = () => { +import { useState, useEffect } from "react"; +import SignupForm from "./components/SignupForm"; +import LoginForm from "./components/LoginForm"; + +import { API_BASE_URL } from "./constants"; + +const App = () => { + const [user, setUser] = useState(() => { + const saved = localStorage.getItem("user"); + return saved ? JSON.parse(saved) : null; + }); + + const handleLogin = (userData) => { + setUser(userData); + localStorage.setItem("user", JSON.stringify(userData)); + }; + + const handleLogout = () => { + setUser(null); + localStorage.removeItem("user"); + }; + return ( - <> -

Welcome to History!

- +
+ !user ? ( + <> + + + + ) : ( + <> +

Logged in as: {user.username}

+ + + ) +
); }; +export default App; diff --git a/frontend/src/components/FormField.jsx b/frontend/src/components/FormField.jsx new file mode 100644 index 000000000..687b874dc --- /dev/null +++ b/frontend/src/components/FormField.jsx @@ -0,0 +1,27 @@ +const FormField = ({ + label, + name, + type = "text", + value, + onChange, + autoComplete, + error, + required = false, +}) => { + return ( + + ); +}; + +export default FormField; diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 000000000..e468663b9 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { API_BASE_URL } from "../constants"; +import FormField from "./FormField"; + +const LoginForm = ({ handleLogin }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + if (!formData.email || !formData.password) { + setError("Please fill in all fields"); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/users/login`, { + method: "POST", + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + handleLogin(data.response); + setFormData({ email: "", password: "" }); + } catch (error) { + setError("Login failed. Please check your credentials and try again."); + console.log("Login error:", error); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + return ( +
+

Login

+ + + + + + {error &&

{error}

} + + + + ); +}; + +export default LoginForm; diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx new file mode 100644 index 000000000..83d5f26bf --- /dev/null +++ b/frontend/src/components/SignupForm.jsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { API_BASE_URL } from "../constants"; +import FormField from "./FormField"; + +const SignupForm = ({ handleLogin }) => { + const [error, setError] = useState(""); + const [fieldErrors, setFieldErrors] = useState({}); + const [formData, setFormData] = useState({ + username: "", + email: "", + password: "", + }); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setFieldErrors({}); + + try { + const response = await fetch(`${API_BASE_URL}/users/signup`, { + method: "POST", + body: JSON.stringify(formData), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok && response.status > 499) { + throw new Error("Failed to create user"); + } + + const resJson = await response.json(); + + if (!resJson.success) { + throw new Error(resJson.message || "Failed to create user"); + } + + handleLogin(resJson.response); + setFormData({ username: "", email: "", password: "" }); + } catch (error) { + const message = error.message || "Signup failed"; + + if (message.toLowerCase().includes("email")) { + setFieldErrors({ email: message }); + } else if (message.toLowerCase().includes("username")) { + setFieldErrors({ username: message }); + } else if (message.toLowerCase().includes("password")) { + setFieldErrors({ password: message }); + } else { + setError(message); + } + + console.log("Signup error:", error); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + return ( +
+

Sign Up

+ + + + + + + + + + {error &&

{error}

} + + ); +}; + +export default SignupForm; diff --git a/frontend/src/constants.js b/frontend/src/constants.js new file mode 100644 index 000000000..1b098b188 --- /dev/null +++ b/frontend/src/constants.js @@ -0,0 +1 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; From a36ef63f5df5ebdce2af198ad72b57686673da60 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 20 Feb 2026 13:47:48 +0100 Subject: [PATCH 07/36] add project skeleton with router, zustand store, and react query --- frontend/package.json | 5 ++++- frontend/src/api/http.js | 0 frontend/src/api/queryKeys.js | 12 ++++++++++++ frontend/src/app/queryClient.js | 11 +++++++++++ frontend/src/app/router.jsx | 0 frontend/src/components/protectedRoute.jsx | 0 frontend/src/pages/AppLayout.jsx | 0 frontend/src/pages/LoginPage.jsx | 0 frontend/src/pages/RegisterPage.jsx | 0 frontend/src/pages/TimelinePage.jsx | 0 frontend/src/stores/authStore.js | 0 frontend/src/stores/uiStore.js | 0 12 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/http.js create mode 100644 frontend/src/api/queryKeys.js create mode 100644 frontend/src/app/queryClient.js create mode 100644 frontend/src/app/router.jsx create mode 100644 frontend/src/components/protectedRoute.jsx create mode 100644 frontend/src/pages/AppLayout.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 frontend/src/pages/TimelinePage.jsx create mode 100644 frontend/src/stores/authStore.js create mode 100644 frontend/src/stores/uiStore.js diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..91d5b1ea7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.90.21", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/api/queryKeys.js b/frontend/src/api/queryKeys.js new file mode 100644 index 000000000..33819233e --- /dev/null +++ b/frontend/src/api/queryKeys.js @@ -0,0 +1,12 @@ +export const qk = { + layers: ["layers"], + timelineEvents: (layerId, viewport, filters) => [ + "timelineEvents", + layerId, + viewport, + filters, + ], + eventDetails: (eventId) => ["eventDetails", eventId], + savedComparisons: ["savedComparisons"], + wikidataEntitySearch: (query) => ["wikidataEntitySearch", query], +}; diff --git a/frontend/src/app/queryClient.js b/frontend/src/app/queryClient.js new file mode 100644 index 000000000..0c76a9f36 --- /dev/null +++ b/frontend/src/app/queryClient.js @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/frontend/src/app/router.jsx b/frontend/src/app/router.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/components/protectedRoute.jsx b/frontend/src/components/protectedRoute.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/pages/AppLayout.jsx b/frontend/src/pages/AppLayout.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/pages/TimelinePage.jsx b/frontend/src/pages/TimelinePage.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/stores/uiStore.js b/frontend/src/stores/uiStore.js new file mode 100644 index 000000000..e69de29bb From ca0bbd352771fc5f7b5fd3a57ed1ce773dbb5c59 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 25 Feb 2026 11:42:27 +0100 Subject: [PATCH 08/36] added Layer and Event Schemas --- .vscode/settings.json | 3 +++ backend/models/Event.js | 50 +++++++++++++++++++++++++++++++++++++++++ backend/models/Layer.js | 30 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 backend/models/Event.js create mode 100644 backend/models/Layer.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..d3cb2ac4d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} diff --git a/backend/models/Event.js b/backend/models/Event.js new file mode 100644 index 000000000..6dad4f4f4 --- /dev/null +++ b/backend/models/Event.js @@ -0,0 +1,50 @@ +import mongoose, { Schema } from "mongoose"; + +const SourceSchema = new Schema({ label: String, url: String }, { _id: false }); + +const WikimediaSchema = new Schema( + { imageUrl: String, credit: String, licenceUrl: String }, + { _id: false }, +); + +const EventSchema = new Schema( + { + layerId: { + type: Schema.Types.ObjectId, + ref: "Layer", + required: true, + index: true, + }, + + title: { type: String, required: true }, + summary: { type: String }, + + startDate: { type: Date, required: true, index: true }, + endDate: { type: Date }, + + category: { type: String, required: true, index: true }, + tags: { type: [String], default: [] }, + location: { type: String }, + + sources: { type: [SourceSchema], default: [] }, + wikimedia: { type: WikimediaSchema }, + externalIds: { + wikidataQid: { type: String, index: true }, + }, + lastSyncedAt: { type: Date }, + }, + { timestamps: true }, +); + +EventSchema.index({ layerId: 1, startDate: 1 }); +EventSchema.index({ layerId: 1, category: 1, startDate: 1 }); + +EventSchema.index( + { layerId: 1, "externalIds.wikidataQid": 1 }, + { + unique: true, + partialFilterExpression: { "externalIds.wikidataQid": { $type: "string" } }, + }, +); + +export default mongoose.model("Event", EventSchema); diff --git a/backend/models/Layer.js b/backend/models/Layer.js new file mode 100644 index 000000000..2dd227fab --- /dev/null +++ b/backend/models/Layer.js @@ -0,0 +1,30 @@ +import mongoose, { Schema } from "mongoose"; + +const LayerSchema = new Schema( + { + name: { type: String, required: true }, + slug: { type: String, required: true, unique: true }, + region: { + type: String, + enum: ["Europe"], + default: "Europe", + required: true, + }, + + rangeStart: { type: Date, required: true }, + rangeEnd: { type: Date, required: true }, + + categories: { + type: [String], + required: true, + }, + + isPublic: { type: Boolean, default: true }, + ownerId: { type: Schema.Types.ObjectId, ref: "User", index: true }, + }, + { timestamps: true }, +); + +LayerSchema.index({ isPublic: 1, ownerId: 1 }); + +export default mongoose.model("Layer", LayerSchema); From cc2c5a8d2d53b61a5e33c6bee18a45dd367fb19c Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 25 Feb 2026 13:43:11 +0100 Subject: [PATCH 09/36] feat: add layer seeding, event schema, and auth store --- backend/models/Event.js | 2 +- backend/package.json | 3 +- backend/seeds/layers.seed.js | 35 ++++++++++++++++ backend/seeds/seedLayers.js | 72 ++++++++++++++++++++++++++++++++ backend/server.js | 4 +- frontend/src/stores/authStore.js | 28 +++++++++++++ 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 backend/seeds/layers.seed.js create mode 100644 backend/seeds/seedLayers.js diff --git a/backend/models/Event.js b/backend/models/Event.js index 6dad4f4f4..8ef418a2e 100644 --- a/backend/models/Event.js +++ b/backend/models/Event.js @@ -3,7 +3,7 @@ import mongoose, { Schema } from "mongoose"; const SourceSchema = new Schema({ label: String, url: String }, { _id: false }); const WikimediaSchema = new Schema( - { imageUrl: String, credit: String, licenceUrl: String }, + { imageUrl: String, credit: String, licenseUrl: String }, { _id: false }, ); diff --git a/backend/package.json b/backend/package.json index 12695a06a..2c5c79ca7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "description": "Server part of final project", "scripts": { "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "dev": "nodemon server.js --exec babel-node", + "seed:layers": "babel-node seeds/seedLayers.js" }, "author": "", "license": "ISC", diff --git a/backend/seeds/layers.seed.js b/backend/seeds/layers.seed.js new file mode 100644 index 000000000..3b0b5d719 --- /dev/null +++ b/backend/seeds/layers.seed.js @@ -0,0 +1,35 @@ +export const layerSeed = [ + { + name: "War & Organized Violence", + slug: "war_organized_violence_europe", + region: "Europe", + rangeStart: new Date("1500-01-01"), + rangeEnd: new Date("2000-12-31"), + categories: [ + "interstate_wars", + "civil_wars", + "revolutions_uprisings", + "genocides_mass_violence", + "military_alliances", + ], + isPublic: true, + ownerId: null, + }, + { + name: "Medicine & Disease", + slug: "medicine_disease_europe", + region: "Europe", + rangeStart: new Date("1500-01-01"), + rangeEnd: new Date("2000-12-31"), + categories: [ + "major_epidemics_pandemics", + "vaccines", + "medical_breakthroughs", + "public_health_reforms", + "hospital_systems", + "germ_theory_bacteriology", + ], + isPublic: true, + ownerId: null, + }, +]; diff --git a/backend/seeds/seedLayers.js b/backend/seeds/seedLayers.js new file mode 100644 index 000000000..851aa95d3 --- /dev/null +++ b/backend/seeds/seedLayers.js @@ -0,0 +1,72 @@ +import "dotenv/config"; +import mongoose from "mongoose"; +import layer from "../models/Layer.js"; +import { layerSeed } from "./layers.seed.js"; + +async function connectDB() { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error("Missing MONGO_URI in .env"); + await mongoose.connect(uri); +} + +function summarizeBulkResult(result) { + // Mongoose/MongoDB kan skilja i hur resultatobjektet ser ut mellan versioner, + // så plockar säkert ut “vanliga” fält. + return { + insertedCount: result?.insertedCount ?? 0, + matchedCount: result?.matchedCount ?? 0, + modifiedCount: result?.modifiedCount ?? 0, + upsertedCount: result?.upsertedCount ?? 0, + upsertedIds: result?.upsertedIds ?? result?.getUpsertedIds?.() ?? [], + }; +} + +async function seedLayers() { + await connectDB(); + + console.log("Seeding layers..."); + console.log( + "Targets:", + layersSeed.map((l) => l.slug), + ); + + const ops = layersSeed.map((layer) => ({ + updateOne: { + filter: { slug: layer.slug }, + update: { $set: layer }, + upsert: true, + }, + })); + + const bulkResult = await Layer.bulkWrite(ops, { ordered: true }); + const summary = summarizeBulkResult(bulkResult); + + const total = await Layer.countDocuments(); + const systemLayers = await Layer.countDocuments({ ownerId: null }); + const publicLayers = await Layer.countDocuments({ isPublic: true }); + + console.log("Seed complete."); + console.log("Bulk summary:", summary); + console.log("DB counts:", { total, systemLayers, publicLayers }); + + // Visa vad som ligger i DB (snabbt och tydligt) + const current = await Layer.find( + { slug: { $in: layersSeed.map((l) => l.slug) } }, + { _id: 1, slug: 1, name: 1, region: 1, isPublic: 1, ownerId: 1 }, + ).lean(); + + console.log("Seeded docs (slug -> _id):"); + for (const doc of current) { + console.log(`- ${doc.slug} -> ${doc._id}`); + } + + await mongoose.disconnect(); +} + +seedLayers().catch(async (err) => { + console.error("Seeding failed:", err); + try { + await mongoose.disconnect(); + } catch (_) {} + process.exit(1); +}); diff --git a/backend/server.js b/backend/server.js index 216fb383d..ca587603c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,10 +5,10 @@ import mongoose from "mongoose"; import userRoutes from "./routes/userRoutes.js"; import listEndPoints from "express-list-endpoints"; -const mongoUrl = process.env.MONGO_URL; +const mongoUri = process.env.MONGO_URI; try { - await mongoose.connect(mongoUrl); + await mongoose.connect(mongoUri); console.log("Connected to MongoDB"); } catch (error) { console.error("MongoDB connection error:", error); diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index e69de29bb..436c05452 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -0,0 +1,28 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const useAuthStore = create( + persist( + (set, get) => ({ + user: null, + accessToken: null, + + isAuthenticated: () => !!get().accessToken, + + setAuth: ({ user, accessToken }) => + set({ + user: user ?? null, + accessToken: accessToken ?? null, + }), + + logout: () => + set({ + user: null, + accessToken: null, + }), + }), + { + name: "auth", + }, + ), +); From 0eb485bdecdc0c09fbd83266a9bddaddd12d60db Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 25 Feb 2026 16:06:51 +0100 Subject: [PATCH 10/36] refactor: move to feature-based backend architecture (layers, db, integrations) --- backend/api/layers/layers.controller.js | 36 ++++++++++++++++ backend/api/layers/layers.routes.js | 10 +++++ backend/api/layers/layers.service.js | 51 +++++++++++++++++++++++ backend/{seeds => db/seed}/layers.seed.js | 2 +- backend/{seeds => db/seed}/seedLayers.js | 10 ++--- backend/package.json | 2 +- backend/routes/layersRoutes.js | 0 backend/server.js | 2 + 8 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 backend/api/layers/layers.controller.js create mode 100644 backend/api/layers/layers.routes.js create mode 100644 backend/api/layers/layers.service.js rename backend/{seeds => db/seed}/layers.seed.js (96%) rename backend/{seeds => db/seed}/seedLayers.js (89%) create mode 100644 backend/routes/layersRoutes.js diff --git a/backend/api/layers/layers.controller.js b/backend/api/layers/layers.controller.js new file mode 100644 index 000000000..bbbb0cf43 --- /dev/null +++ b/backend/api/layers/layers.controller.js @@ -0,0 +1,36 @@ +// backend/api/layers/layers.controller.js +import { getPublicLayers, getLayerEvents } from "./layers.service.js"; + +export async function listLayers(_req, res) { + try { + const layers = await getPublicLayers(); + res.json({ + success: true, + message: "Layers fetched", + response: layers, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to fetch layers", + response: error.message, + }); + } +} + +export async function listLayerEvents(req, res) { + try { + const data = await getLayerEvents(req.params.id, req.query); + res.json({ + success: true, + message: "Events fetched", + response: data, + }); + } catch (error) { + res.status(error.status || 500).json({ + success: false, + message: error.message || "Failed to fetch events", + response: null, + }); + } +} diff --git a/backend/api/layers/layers.routes.js b/backend/api/layers/layers.routes.js new file mode 100644 index 000000000..d728b9d11 --- /dev/null +++ b/backend/api/layers/layers.routes.js @@ -0,0 +1,10 @@ +// backend/api/layers/layers.routes.js +import express from "express"; +import { listLayers, listLayerEvents } from "./layers.controller.js"; + +const router = express.Router(); + +router.get("/", listLayers); +router.get("/:id/events", listLayerEvents); + +export default router; diff --git a/backend/api/layers/layers.service.js b/backend/api/layers/layers.service.js new file mode 100644 index 000000000..2c504a397 --- /dev/null +++ b/backend/api/layers/layers.service.js @@ -0,0 +1,51 @@ +// backend/api/layers/layers.service.js +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; + +export async function getPublicLayers() { + return Layer.find({ isPublic: true, ownerId: null }) + .sort({ createdAt: 1 }) + .lean(); +} + +export async function getLayerEvents(layerId, { from, to, category, tag }) { + const layer = await Layer.findById(layerId).lean(); + if (!layer) { + const err = new Error("Layer not found"); + err.status = 404; + throw err; + } + + const fromDate = from ? new Date(from) : layer.rangeStart; + const toDate = to ? new Date(to) : layer.rangeEnd; + + if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) { + const err = new Error("Invalid date format. Use YYYY-MM-DD"); + err.status = 400; + throw err; + } + + const query = { + layerId: layer._id, + startDate: { $gte: fromDate, $lte: toDate }, + }; + + if (category) query.category = category; + if (tag) query.tags = tag; + + const events = await Event.find(query).sort({ startDate: 1 }).lean(); + + return { + layer: { + _id: layer._id, + name: layer.name, + slug: layer.slug, + region: layer.region, + categories: layer.categories, + }, + from: fromDate, + to: toDate, + count: events.length, + events, + }; +} diff --git a/backend/seeds/layers.seed.js b/backend/db/seed/layers.seed.js similarity index 96% rename from backend/seeds/layers.seed.js rename to backend/db/seed/layers.seed.js index 3b0b5d719..4e1f0d22a 100644 --- a/backend/seeds/layers.seed.js +++ b/backend/db/seed/layers.seed.js @@ -1,4 +1,4 @@ -export const layerSeed = [ +export const layersSeed = [ { name: "War & Organized Violence", slug: "war_organized_violence_europe", diff --git a/backend/seeds/seedLayers.js b/backend/db/seed/seedLayers.js similarity index 89% rename from backend/seeds/seedLayers.js rename to backend/db/seed/seedLayers.js index 851aa95d3..28fa739a4 100644 --- a/backend/seeds/seedLayers.js +++ b/backend/db/seed/seedLayers.js @@ -1,7 +1,7 @@ import "dotenv/config"; import mongoose from "mongoose"; -import layer from "../models/Layer.js"; -import { layerSeed } from "./layers.seed.js"; +import Layer from "../../models/Layer.js"; +import { layersSeed } from "./layers.seed.js"; async function connectDB() { const uri = process.env.MONGO_URI; @@ -30,10 +30,10 @@ async function seedLayers() { layersSeed.map((l) => l.slug), ); - const ops = layersSeed.map((layer) => ({ + const ops = layersSeed.map((layerDoc) => ({ updateOne: { - filter: { slug: layer.slug }, - update: { $set: layer }, + filter: { slug: layerDoc.slug }, + update: { $set: layerDoc }, upsert: true, }, })); diff --git a/backend/package.json b/backend/package.json index 2c5c79ca7..0dc3bf4df 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node", - "seed:layers": "babel-node seeds/seedLayers.js" + "seed:layers": "babel-node db/seed/seedLayers.js" }, "author": "", "license": "ISC", diff --git a/backend/routes/layersRoutes.js b/backend/routes/layersRoutes.js new file mode 100644 index 000000000..e69de29bb diff --git a/backend/server.js b/backend/server.js index ca587603c..a233f3974 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,7 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; import userRoutes from "./routes/userRoutes.js"; +import layersRoutes from "./api/layers/layers.routes.js"; import listEndPoints from "express-list-endpoints"; const mongoUri = process.env.MONGO_URI; @@ -23,6 +24,7 @@ app.use(express.json()); // routes app.use("/users", userRoutes); +app.use("/layers", layersRoutes); const endpoints = listEndPoints(app); From 6e2ced466a734199b4b106d5fbe5bbf629733d13 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 26 Feb 2026 15:57:31 +0100 Subject: [PATCH 11/36] feat: add Wikidata import pipeline for war and medicine layers - Add war.query.js, war.mapper.js, war.job.js - Add medicine.query.js, medicine.mapper.js, medicine.job.js - Add shared _mapperUtils.js (extractQid, buildEventDoc) - Add shared _runImport.js (runImport, startFromCLI) - Fix event query overlap logic in layers.service.js - War import verified: 1333 events upserted to MongoDB - Medicine import pending: query needs debugging --- backend/api/layers/layers.service.js | 6 +- .../wikidata/mappers/_mapperUtils.js | 59 ++++++++++ .../wikidata/mappers/medicine.mapper.js | 43 ++++++++ .../wikidata/mappers/war.mapper.js | 36 ++++++ .../wikidata/queries/medicine.query.js | 71 ++++++++++++ .../wikidata/queries/war.query.js | 65 +++++++++++ backend/jobs/import/_runImport.js | 43 ++++++++ backend/jobs/import/medicine.job.js | 103 +++++++++++++++++ backend/jobs/import/war.job.js | 104 ++++++++++++++++++ 9 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 backend/integrations/wikidata/mappers/_mapperUtils.js create mode 100644 backend/integrations/wikidata/mappers/medicine.mapper.js create mode 100644 backend/integrations/wikidata/mappers/war.mapper.js create mode 100644 backend/integrations/wikidata/queries/medicine.query.js create mode 100644 backend/integrations/wikidata/queries/war.query.js create mode 100644 backend/jobs/import/_runImport.js create mode 100644 backend/jobs/import/medicine.job.js create mode 100644 backend/jobs/import/war.job.js diff --git a/backend/api/layers/layers.service.js b/backend/api/layers/layers.service.js index 2c504a397..bed88d019 100644 --- a/backend/api/layers/layers.service.js +++ b/backend/api/layers/layers.service.js @@ -27,7 +27,11 @@ export async function getLayerEvents(layerId, { from, to, category, tag }) { const query = { layerId: layer._id, - startDate: { $gte: fromDate, $lte: toDate }, + $or: [ + { startDate: { $gte: fromDate, $lte: toDate } }, + { endDate: { $gte: fromDate, $lte: toDate } }, + { startDate: { $lte: fromDate }, endDate: { $gte: toDate } }, + ], }; if (category) query.category = category; diff --git a/backend/integrations/wikidata/mappers/_mapperUtils.js b/backend/integrations/wikidata/mappers/_mapperUtils.js new file mode 100644 index 000000000..7c35a1ae3 --- /dev/null +++ b/backend/integrations/wikidata/mappers/_mapperUtils.js @@ -0,0 +1,59 @@ +/** + * Extracts a Wikidata QID from an entity URI. + * e.g. "http://www.wikidata.org/entity/Q362" → "Q362" + * + * @param {string} uri + * @returns {string|null} + */ +export function extractQid(uri) { + if (!uri) return null; + const match = uri.match(/Q\d+$/); + return match ? match[0] : null; +} + +/** + * Builds an Event document from a Wikidata SPARQL result row. + * Used by all mappers (war, medicine, science, etc.). + * + * @param {Object} row - SPARQL result row from Wikidata + * @param {string} layerId - MongoDB ObjectId for the layer + * @param {Function} mapCategory - Category mapping function specific to the layer + * @returns {Object|null} Event document, or null if the row is invalid + */ +export function buildEventDoc(row, layerId, mapCategory) { + const qid = extractQid(row.event?.value); + if (!qid) return null; + + const title = row.eventLabel?.value; + if (!title || title === qid) return null; + + const startDate = row.startDate?.value ? new Date(row.startDate.value) : null; + if (!startDate || isNaN(startDate.getTime())) return null; + + const endDate = row.endDate?.value ? new Date(row.endDate.value) : null; + const location = row.locationLabel?.value || row.countryLabel?.value || null; + + const sources = []; + if (row.article?.value) { + sources.push({ label: "Wikipedia (en)", url: row.article.value }); + } + sources.push({ + label: "Wikidata", + url: `https://www.wikidata.org/wiki/${qid}`, + }); + + return { + layerId, + title, + summary: row.eventDescription?.value || null, + startDate, + endDate: endDate || null, + category: mapCategory(row.instanceLabel?.value), + tags: [], + location, + sources, + wikimedia: null, + externalIds: { wikidataQid: qid }, + lastSyncedAt: new Date(), + }; +} diff --git a/backend/integrations/wikidata/mappers/medicine.mapper.js b/backend/integrations/wikidata/mappers/medicine.mapper.js new file mode 100644 index 000000000..ad711a9f3 --- /dev/null +++ b/backend/integrations/wikidata/mappers/medicine.mapper.js @@ -0,0 +1,43 @@ +// backend/integrations/wikidata/mappers/medicine.mapper.js + +import { buildEventDoc } from "./_mapperUtils.js"; + +function mapCategory(instanceLabel) { + if (!instanceLabel) return "medical_breakthroughs"; + const label = instanceLabel.toLowerCase(); + + if ( + label.includes("epidemic") || + label.includes("pandemic") || + label.includes("plague") + ) { + return "major_epidemics_pandemics"; + } + if (label.includes("vaccine") || label.includes("vaccination")) { + return "vaccines"; + } + if ( + label.includes("public health") || + label.includes("sanitation") || + label.includes("hygiene") + ) { + return "public_health_reforms"; + } + if (label.includes("hospital") || label.includes("clinic")) { + return "hospital_systems"; + } + if ( + label.includes("bacteria") || + label.includes("germ") || + label.includes("bacteriology") || + label.includes("microb") + ) { + return "germ_theory_bacteriology"; + } + + return "medical_breakthroughs"; +} + +export default function mapMedicineEvent(row, layerId) { + return buildEventDoc(row, layerId, mapCategory); +} diff --git a/backend/integrations/wikidata/mappers/war.mapper.js b/backend/integrations/wikidata/mappers/war.mapper.js new file mode 100644 index 000000000..45810d307 --- /dev/null +++ b/backend/integrations/wikidata/mappers/war.mapper.js @@ -0,0 +1,36 @@ +import { buildEventDoc } from "./_mapperUtils.js"; + +/** + * Maps a Wikidata instance label to a war layer category slug. + * + * @param {string} instanceLabel - Instance type label from Wikidata (e.g. "civil war", "genocide") + * @returns {string} Category slug + */ +function mapCategory(instanceLabel) { + if (!instanceLabel) return "interstate_wars"; + const label = instanceLabel.toLowerCase(); + + if (label.includes("civil war")) return "civil_wars"; + if (label.includes("genocide") || label.includes("massacre")) + return "genocides_mass_violence"; + if ( + label.includes("revolution") || + label.includes("uprising") || + label.includes("revolt") || + label.includes("rebellion") + ) + return "revolutions_uprisings"; + + return "interstate_wars"; +} + +/** + * Maps a Wikidata SPARQL result row to a war Event document. + * + * @param {Object} row - SPARQL result row from Wikidata + * @param {string} layerId - MongoDB ObjectId for the war layer + * @returns {Object|null} Event document, or null if the row is invalid + */ +export default function mapWarEvent(row, layerId) { + return buildEventDoc(row, layerId, mapCategory); +} diff --git a/backend/integrations/wikidata/queries/medicine.query.js b/backend/integrations/wikidata/queries/medicine.query.js new file mode 100644 index 000000000..94bdd9200 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine.query.js @@ -0,0 +1,71 @@ +/** + * Returns a SPARQL query for fetching medicine and disease events in Europe + * from Wikidata within a given date range. + * + * @param {Date} rangeStart + * @param {Date} rangeEnd + * @returns {string} SPARQL query + */ +export default function buildMedicineQuery(rangeStart, rangeEnd) { + const from = rangeStart.toISOString().split("T")[0]; + const to = rangeEnd.toISOString().split("T")[0]; + + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?instance ?instanceLabel + ?article +WHERE { + VALUES ?type { + wd:Q1369832 # epidemic + wd:Q178561 # pandemic + wd:Q130003 # infectious disease outbreak + wd:Q11461 # vaccine + wd:Q796194 # medical procedure + wd:Q7314688 # medical discovery + wd:Q726097 # medical treatment + wd:Q4916596 # public health intervention + } + ?event wdt:P31 ?type . + + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + # Exclude military conflicts + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P582 ?endDate . } + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + OPTIONAL { ?event wdt:P31 ?instance . } + + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war.query.js b/backend/integrations/wikidata/queries/war.query.js new file mode 100644 index 000000000..b06402b54 --- /dev/null +++ b/backend/integrations/wikidata/queries/war.query.js @@ -0,0 +1,65 @@ +// backend/integrations/wikidata/queries/war.query.js + +/** + * Returns a SPARQL query for fetching wars and organized violence in Europe + * from Wikidata within a given date range. + * + * @param {Date} rangeStart + * @param {Date} rangeEnd + * @returns {string} SPARQL query + */ +export default function buildWarQuery(rangeStart, rangeEnd) { + const from = rangeStart.toISOString().split("T")[0]; + const to = rangeEnd.toISOString().split("T")[0]; + + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?instance ?instanceLabel + ?article +WHERE { + VALUES ?type { + wd:Q198 # war + wd:Q8465 # civil war + wd:Q13418847 # genocide + wd:Q467011 # rebellion + wd:Q152786 # revolution + wd:Q1261499 # military conflict + wd:Q188055 # massacre + } + + ?event wdt:P31 ?type . + ?event wdt:P580 ?startDate . + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P582 ?endDate . } + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + OPTIONAL { ?event wdt:P31 ?instance . } + + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/jobs/import/_runImport.js b/backend/jobs/import/_runImport.js new file mode 100644 index 000000000..1d0d984cb --- /dev/null +++ b/backend/jobs/import/_runImport.js @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +export async function runImport({ + importFn, + jobName, + dryRun = false, + connectDB = false, +}) { + if (connectDB) { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error("Missing MONGO_URI in .env"); + await mongoose.connect(uri); + console.log(`[${jobName}] Connected to MongoDB`); + } + + try { + console.log(`[${jobName}] Starting import...${dryRun ? " (dry run)" : ""}`); + const result = await importFn({ dryRun }); + console.log(`[${jobName}] Done:`, result); + return result; + } finally { + if (connectDB) { + await mongoose.disconnect(); + console.log(`[${jobName}] Disconnected.`); + } + } +} + +export function startFromCLI( + filename, + importFn, + jobName = filename.replace(".js", ""), +) { + const isMain = process.argv[1]?.endsWith(filename); + if (!isMain) return; + + const dryRun = process.argv.includes("--dry-run"); + + runImport({ importFn, jobName, dryRun, connectDB: true }).catch((err) => { + console.error(`[${jobName}] Import failed:`, err); + process.exit(1); + }); +} diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js new file mode 100644 index 000000000..5a0631aeb --- /dev/null +++ b/backend/jobs/import/medicine.job.js @@ -0,0 +1,103 @@ +import "dotenv/config"; +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; +import buildMedicineQuery from "../../integrations/wikidata/queries/medicine.query.js"; +import mapMedicineEvent from "../../integrations/wikidata/mappers/medicine.mapper.js"; +import { startFromCLI } from "./_runImport.js"; + +const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; +const USER_AGENT = + "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; + +/** + * Fetches raw SPARQL results from Wikidata. + * + * @param {string} sparql + * @returns {Promise} + */ +async function fetchFromWikidata(sparql) { + const url = new URL(WIKIDATA_ENDPOINT); + url.searchParams.set("query", sparql); + url.searchParams.set("format", "json"); + + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error( + `Wikidata responded with ${response.status}: ${await response.text()}`, + ); + } + + const data = await response.json(); + return data.results.bindings; +} + +/** + * Runs the medicine import job. + * Fetches events from Wikidata and upserts them into MongoDB. + * + * @param {{ dryRun?: boolean }} options + * @returns {Promise} Import summary + */ +export async function runMedicineImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ slug: "medicine_disease_europe" }).lean(); + if (!layer) + throw new Error("Medicine layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + + const sparql = buildMedicineQuery(layer.rangeStart, layer.rangeEnd); + const rows = await fetchFromWikidata(sparql); + console.log(`Wikidata returned ${rows.length} rows`); + + const docs = rows + .map((row) => mapMedicineEvent(row, layer._id)) + .filter(Boolean); + console.log( + `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, + ); + + if (dryRun) { + console.log("Sample (first 3):", JSON.stringify(docs.slice(0, 3), null, 2)); + return { + total: rows.length, + mapped: docs.length, + upserted: 0, + dryRun: true, + }; + } + + const ops = docs.map((doc) => ({ + updateOne: { + filter: { + layerId: doc.layerId, + "externalIds.wikidataQid": doc.externalIds.wikidataQid, + }, + update: { $set: doc }, + upsert: true, + }, + })); + + const result = await Event.bulkWrite(ops, { ordered: false }); + + const summary = { + total: rows.length, + mapped: docs.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + dryRun: false, + }; + + console.log("Import complete:", summary); + return summary; +} + +// CLI entry point +// node backend/jobs/import/medicine.job.js +// node backend/jobs/import/medicine.job.js --dry-run +startFromCLI("medicine.job.js", runMedicineImport, "medicine"); diff --git a/backend/jobs/import/war.job.js b/backend/jobs/import/war.job.js new file mode 100644 index 000000000..1ae0ead3f --- /dev/null +++ b/backend/jobs/import/war.job.js @@ -0,0 +1,104 @@ +// backend/jobs/import/war.job.js + +import "dotenv/config"; +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; +import buildWarQuery from "../../integrations/wikidata/queries/war.query.js"; +import mapWarEvent from "../../integrations/wikidata/mappers/war.mapper.js"; +import { runImport, startFromCLI } from "./_runImport.js"; + +const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; +const USER_AGENT = + "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; + +/** + * Fetches raw SPARQL results from Wikidata. + * + * @param {string} sparql + * @returns {Promise} + */ +async function fetchFromWikidata(sparql) { + const url = new URL(WIKIDATA_ENDPOINT); + url.searchParams.set("query", sparql); + url.searchParams.set("format", "json"); + + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error( + `Wikidata responded with ${response.status}: ${await response.text()}`, + ); + } + + const data = await response.json(); + return data.results.bindings; +} + +/** + * Runs the war import job. + * Fetches events from Wikidata and upserts them into MongoDB. + * + * @param {{ dryRun?: boolean }} options + * @returns {Promise} Import summary + */ +export async function runWarImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ + slug: "war_organized_violence_europe", + }).lean(); + if (!layer) throw new Error("War layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + + const sparql = buildWarQuery(layer.rangeStart, layer.rangeEnd); + const rows = await fetchFromWikidata(sparql); + console.log(`Wikidata returned ${rows.length} rows`); + + const docs = rows.map((row) => mapWarEvent(row, layer._id)).filter(Boolean); + console.log( + `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, + ); + + if (dryRun) { + console.log("Sample (first 3):", JSON.stringify(docs.slice(0, 3), null, 2)); + return { + total: rows.length, + mapped: docs.length, + upserted: 0, + dryRun: true, + }; + } + + const ops = docs.map((doc) => ({ + updateOne: { + filter: { + layerId: doc.layerId, + "externalIds.wikidataQid": doc.externalIds.wikidataQid, + }, + update: { $set: doc }, + upsert: true, + }, + })); + + const result = await Event.bulkWrite(ops, { ordered: false }); + + const summary = { + total: rows.length, + mapped: docs.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + dryRun: false, + }; + + console.log("Import complete:", summary); + return summary; +} + +// CLI entry point +// node backend/jobs/import/war.job.js +// node backend/jobs/import/war.job.js --dry-run +startFromCLI("war.job.js", runWarImport, "war"); From c178049aa888b3cde2f585f4a661d0af8e17e3a0 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 27 Feb 2026 10:28:02 +0100 Subject: [PATCH 12/36] medicine query debugged --- .../integrations/wikidata/queries/medicine.query.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/integrations/wikidata/queries/medicine.query.js b/backend/integrations/wikidata/queries/medicine.query.js index 94bdd9200..ecd475674 100644 --- a/backend/integrations/wikidata/queries/medicine.query.js +++ b/backend/integrations/wikidata/queries/medicine.query.js @@ -18,16 +18,17 @@ SELECT DISTINCT ?instance ?instanceLabel ?article WHERE { - VALUES ?type { - wd:Q1369832 # epidemic + VALUES ?type { + wd:Q44512 # epidemic + wd:Q1516910 # plague epidemic + wd:Q2723958 # influenza pandemic wd:Q178561 # pandemic - wd:Q130003 # infectious disease outbreak + wd:Q1369832 # disease outbreak wd:Q11461 # vaccine wd:Q796194 # medical procedure wd:Q7314688 # medical discovery - wd:Q726097 # medical treatment - wd:Q4916596 # public health intervention } + ?event wdt:P31 ?type . { From 71205027bb09c3f31c5ac475f26ec93ef3e70a21 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 27 Feb 2026 12:25:19 +0100 Subject: [PATCH 13/36] feat: add frontend timeline with layer comparison - Add TimelinePage with horizontal timeline visualization - Add EventDot, TimelineRow, EventPanel, YearAxis, LayerSelector components - Add CSS modules for all timeline components - Add AppLayout with navbar - Add React Router, React Query, Zustand wiring - Fix API URL and response parsing --- backend/api/layers/layers.controller.js | 1 - backend/api/layers/layers.routes.js | 1 - backend/server.js | 2 +- frontend/src/App.jsx | 45 ++------- frontend/src/api/http.js | 24 +++++ frontend/src/api/queryKeys.js | 11 +-- frontend/src/app/router.jsx | 17 ++++ frontend/src/features/layers/api.js | 17 ++++ frontend/src/features/layers/hooks.js | 18 ++++ .../src/features/timeline/TimelinePage.jsx | 94 +++++++++++++++++++ .../features/timeline/components/EventDot.jsx | 20 ++++ .../timeline/components/EventPanel.jsx | 43 +++++++++ .../timeline/components/LayerSelector.jsx | 23 +++++ .../timeline/components/TimelineRow.jsx | 32 +++++++ .../features/timeline/components/YearAxis.jsx | 22 +++++ frontend/src/features/timeline/constants.js | 33 +++++++ .../timeline/styles/EventDot.module.css | 30 ++++++ .../timeline/styles/EventPanel.module.css | 79 ++++++++++++++++ .../timeline/styles/LayerSelector.module.css | 31 ++++++ .../timeline/styles/TimelinePage.module.css | 42 +++++++++ .../timeline/styles/TimelineRow.module.css | 36 +++++++ .../timeline/styles/YearAxis.module.css | 15 +++ frontend/src/layouts/AppLayout.jsx | 41 ++++++++ frontend/src/layouts/AppLayout.module.css | 71 ++++++++++++++ frontend/src/main.jsx | 4 +- frontend/src/pages/AppLayout.jsx | 0 frontend/src/pages/LoginPage.jsx | 7 ++ frontend/src/pages/RegisterPage.jsx | 7 ++ frontend/src/pages/TimelinePage.jsx | 0 29 files changed, 716 insertions(+), 50 deletions(-) create mode 100644 frontend/src/features/layers/api.js create mode 100644 frontend/src/features/layers/hooks.js create mode 100644 frontend/src/features/timeline/TimelinePage.jsx create mode 100644 frontend/src/features/timeline/components/EventDot.jsx create mode 100644 frontend/src/features/timeline/components/EventPanel.jsx create mode 100644 frontend/src/features/timeline/components/LayerSelector.jsx create mode 100644 frontend/src/features/timeline/components/TimelineRow.jsx create mode 100644 frontend/src/features/timeline/components/YearAxis.jsx create mode 100644 frontend/src/features/timeline/constants.js create mode 100644 frontend/src/features/timeline/styles/EventDot.module.css create mode 100644 frontend/src/features/timeline/styles/EventPanel.module.css create mode 100644 frontend/src/features/timeline/styles/LayerSelector.module.css create mode 100644 frontend/src/features/timeline/styles/TimelinePage.module.css create mode 100644 frontend/src/features/timeline/styles/TimelineRow.module.css create mode 100644 frontend/src/features/timeline/styles/YearAxis.module.css create mode 100644 frontend/src/layouts/AppLayout.jsx create mode 100644 frontend/src/layouts/AppLayout.module.css delete mode 100644 frontend/src/pages/AppLayout.jsx delete mode 100644 frontend/src/pages/TimelinePage.jsx diff --git a/backend/api/layers/layers.controller.js b/backend/api/layers/layers.controller.js index bbbb0cf43..e865421bd 100644 --- a/backend/api/layers/layers.controller.js +++ b/backend/api/layers/layers.controller.js @@ -1,4 +1,3 @@ -// backend/api/layers/layers.controller.js import { getPublicLayers, getLayerEvents } from "./layers.service.js"; export async function listLayers(_req, res) { diff --git a/backend/api/layers/layers.routes.js b/backend/api/layers/layers.routes.js index d728b9d11..f4cc607cf 100644 --- a/backend/api/layers/layers.routes.js +++ b/backend/api/layers/layers.routes.js @@ -1,4 +1,3 @@ -// backend/api/layers/layers.routes.js import express from "express"; import { listLayers, listLayerEvents } from "./layers.controller.js"; diff --git a/backend/server.js b/backend/server.js index a233f3974..2c5a386b5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,7 +24,7 @@ app.use(express.json()); // routes app.use("/users", userRoutes); -app.use("/layers", layersRoutes); +app.use("/api/layers", layersRoutes); const endpoints = listEndPoints(app); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9410ca0b7..f054279cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,39 +1,12 @@ -import { useState, useEffect } from "react"; -import SignupForm from "./components/SignupForm"; -import LoginForm from "./components/LoginForm"; - -import { API_BASE_URL } from "./constants"; - -const App = () => { - const [user, setUser] = useState(() => { - const saved = localStorage.getItem("user"); - return saved ? JSON.parse(saved) : null; - }); - - const handleLogin = (userData) => { - setUser(userData); - localStorage.setItem("user", JSON.stringify(userData)); - }; - - const handleLogout = () => { - setUser(null); - localStorage.removeItem("user"); - }; +import { RouterProvider } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { router } from "./app/router"; +import { queryClient } from "./app/queryClient"; +export default function App() { return ( -
- !user ? ( - <> - - - - ) : ( - <> -

Logged in as: {user.username}

- - - ) -
+ + + ); -}; -export default App; +} diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js index e69de29bb..af9f1430a 100644 --- a/frontend/src/api/http.js +++ b/frontend/src/api/http.js @@ -0,0 +1,24 @@ +import { useAuthStore } from "../stores/authStore"; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080"; + +export async function http(path, options = {}) { + const token = useAuthStore.getState().accessToken; + + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + ...options.headers, + }, + }); + + if (!res.ok) { + const error = new Error(`HTTP ${res.status}`); + error.status = res.status; + throw error; + } + + return res.json(); +} diff --git a/frontend/src/api/queryKeys.js b/frontend/src/api/queryKeys.js index 33819233e..51f3b9faf 100644 --- a/frontend/src/api/queryKeys.js +++ b/frontend/src/api/queryKeys.js @@ -1,12 +1,5 @@ -export const qk = { +export const queryKeys = { layers: ["layers"], - timelineEvents: (layerId, viewport, filters) => [ - "timelineEvents", - layerId, - viewport, - filters, - ], - eventDetails: (eventId) => ["eventDetails", eventId], + layerEvents: (layerId, params) => ["layers", layerId, "events", params], savedComparisons: ["savedComparisons"], - wikidataEntitySearch: (query) => ["wikidataEntitySearch", query], }; diff --git a/frontend/src/app/router.jsx b/frontend/src/app/router.jsx index e69de29bb..fb7cb4e2d 100644 --- a/frontend/src/app/router.jsx +++ b/frontend/src/app/router.jsx @@ -0,0 +1,17 @@ +import { createBrowserRouter } from "react-router-dom"; +import AppLayout from "../layouts/AppLayout"; +import TimelinePage from "../features/timeline/TimelinePage"; +import LoginPage from "../pages/LoginPage"; +import RegisterPage from "../pages/RegisterPage"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "login", element: }, + { path: "register", element: }, + ], + }, +]); diff --git a/frontend/src/features/layers/api.js b/frontend/src/features/layers/api.js new file mode 100644 index 000000000..372378e8e --- /dev/null +++ b/frontend/src/features/layers/api.js @@ -0,0 +1,17 @@ +import { http } from "../../api/http"; + +export async function fetchLayers() { + const data = await http("/api/layers"); + return data.response; +} + +export async function fetchLayerEvents(layerId, { from, to, category } = {}) { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + if (category) params.set("category", category); + + const query = params.toString() ? `?${params.toString()}` : ""; + const data = await http(`/api/layers/${layerId}/events${query}`); + return data.response; +} diff --git a/frontend/src/features/layers/hooks.js b/frontend/src/features/layers/hooks.js new file mode 100644 index 000000000..f7761a245 --- /dev/null +++ b/frontend/src/features/layers/hooks.js @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "../../api/queryKeys"; +import { fetchLayers, fetchLayerEvents } from "./api"; + +export function useLayers() { + return useQuery({ + queryKey: queryKeys.layers, + queryFn: fetchLayers, + }); +} + +export function useLayerEvents(layerId, params = {}) { + return useQuery({ + queryKey: queryKeys.layerEvents(layerId, params), + queryFn: () => fetchLayerEvents(layerId, params), + enabled: !!layerId, + }); +} diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx new file mode 100644 index 000000000..92c778c1e --- /dev/null +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -0,0 +1,94 @@ +import { useState, useCallback, useEffect } from "react"; +import { useLayers } from "../layers/hooks"; +import { useLayerEvents } from "../layers/hooks"; +import LayerSelector from "./components/LayerSelector"; +import TimelineRow from "./components/TimelineRow"; +import YearAxis from "./components/YearAxis"; +import EventPanel from "./components/EventPanel"; +import styles from "./styles/TimelinePage.module.css"; + +function ActiveLayer({ layerId, layers, selectedEvent, onEventClick }) { + const layer = layers.find((l) => l._id === layerId); + const { data, isLoading, isError } = useLayerEvents(layerId); + + if (!layer) return null; + if (isLoading) + return

Loading {layer.name}...

; + if (isError) + return

Failed to load {layer.name}

; + + return ( + + ); +} + +export default function TimelinePage() { + const { data: layersData, isLoading, isError } = useLayers(); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const [selectedEvent, setSelectedEvent] = useState(null); + + const layers = layersData ?? []; + + useEffect(() => { + if (layers.length > 0 && selectedLayerIds.length === 0) { + setSelectedLayerIds([layers[0]._id]); + } + }, [layers]); + + const handleToggleLayer = useCallback((id) => { + setSelectedLayerIds((prev) => { + if (prev.includes(id)) return prev.filter((x) => x !== id); + if (prev.length >= 2) return [...prev.slice(1), id]; + return [...prev, id]; + }); + }, []); + + const handleEventClick = useCallback((event) => { + setSelectedEvent((prev) => (prev?._id === event._id ? null : event)); + }, []); + + if (isLoading) return

Loading layers...

; + if (isError) + return

Failed to load layers.

; + + return ( +
+
+

Historical Timeline

+

Europe · 1500–2000

+
+ + + +
+ {selectedLayerIds.length === 0 && ( +

Select a layer above to begin.

+ )} + {selectedLayerIds.map((id) => ( + + ))} + +
+ + setSelectedEvent(null)} + /> +
+ ); +} diff --git a/frontend/src/features/timeline/components/EventDot.jsx b/frontend/src/features/timeline/components/EventDot.jsx new file mode 100644 index 000000000..224b21df5 --- /dev/null +++ b/frontend/src/features/timeline/components/EventDot.jsx @@ -0,0 +1,20 @@ +import { CATEGORY_COLORS, dateToPercent } from "../constants"; +import styles from "../styles/EventDot.module.css"; + +export default function EventDot({ event, onClick, isSelected }) { + const color = CATEGORY_COLORS[event.category] ?? "#888"; + const left = dateToPercent(event.startDate); + + return ( +
onClick(event)} + title={event.title} + style={{ + left, + "--color": color, + "--left": `${left}%`, + }} + /> + ); +} diff --git a/frontend/src/features/timeline/components/EventPanel.jsx b/frontend/src/features/timeline/components/EventPanel.jsx new file mode 100644 index 000000000..e1b2c8630 --- /dev/null +++ b/frontend/src/features/timeline/components/EventPanel.jsx @@ -0,0 +1,43 @@ +import { CATEGORY_COLORS, formatYear } from "../constants"; +import styles from "../styles/EventPanel.module.css"; + +export default function EventPanel({ event, onClose }) { + if (!event) return null; + const color = CATEGORY_COLORS[event.category] ?? "#888"; + + return ( +
+ + +
{event.category.replace(/_/g, " ")}
+ +

{event.title}

+ +
+ {formatYear(event.startDate)} + {event.endDate && ` – ${formatYear(event.endDate)}`} + {event.location && ` · ${event.location}`} +
+ + {event.summary &&

{event.summary}

} + + {event.sources?.length > 0 && ( +
+ {event.sources.map((s, i) => ( + + ↗ {s.label} + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/features/timeline/components/LayerSelector.jsx b/frontend/src/features/timeline/components/LayerSelector.jsx new file mode 100644 index 000000000..d51dd69e7 --- /dev/null +++ b/frontend/src/features/timeline/components/LayerSelector.jsx @@ -0,0 +1,23 @@ +import { LAYER_ACCENT } from "../constants"; +import styles from "../styles/LayerSelector.module.css"; + +export default function LayerSelector({ layers, selectedIds, onToggle }) { + return ( +
+ {layers.map((layer) => { + const active = selectedIds.includes(layer._id); + const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx new file mode 100644 index 000000000..2e7844d4b --- /dev/null +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -0,0 +1,32 @@ +import { LAYER_ACCENT } from "../constants"; +import EventDot from "./EventDot"; +import styles from "../styles/TimelineRow.module.css"; + +export default function TimelineRow({ + layer, + events, + selectedEvent, + onEventClick, +}) { + const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + + return ( +
+
+ {layer.name} + {events.length} events +
+
+
+ {events.map((event) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/features/timeline/components/YearAxis.jsx b/frontend/src/features/timeline/components/YearAxis.jsx new file mode 100644 index 000000000..6b0553c85 --- /dev/null +++ b/frontend/src/features/timeline/components/YearAxis.jsx @@ -0,0 +1,22 @@ +import { dateToPercent } from "../constants"; +import styles from "../styles/YearAxis.module.css"; + +const YEARS = [ + 1500, 1550, 1600, 1650, 1700, 1750, 1800, 1850, 1900, 1950, 2000, +]; + +export default function YearAxis() { + return ( +
+ {YEARS.map((year) => ( +
+ {year} +
+ ))} +
+ ); +} diff --git a/frontend/src/features/timeline/constants.js b/frontend/src/features/timeline/constants.js new file mode 100644 index 000000000..d8fca3c87 --- /dev/null +++ b/frontend/src/features/timeline/constants.js @@ -0,0 +1,33 @@ +export const CATEGORY_COLORS = { + // War + interstate_wars: "#c0392b", + civil_wars: "#e67e22", + revolutions_uprisings: "#f1c40f", + genocides_mass_violence: "#8e1a1a", + military_alliances: "#3498db", + // Medicine + major_epidemics_pandemics: "#9b59b6", + vaccines: "#27ae60", + medical_breakthroughs: "#1abc9c", + public_health_reforms: "#16a085", + hospital_systems: "#2980b9", + germ_theory_bacteriology: "#6c3483", +}; + +export const LAYER_ACCENT = { + war_organized_violence_europe: "#c0392b", + medicine_disease_europe: "#9b59b6", +}; + +export const RANGE_START = new Date("1500-01-01").getTime(); +export const RANGE_END = new Date("2000-01-01").getTime(); +export const RANGE_SPAN = RANGE_END - RANGE_START; + +export function dateToPercent(date) { + const t = new Date(date).getTime(); + return ((t - RANGE_START) / RANGE_SPAN) * 100; +} + +export function formatYear(date) { + return new Date(date).getFullYear(); +} diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css new file mode 100644 index 000000000..482bf0e8d --- /dev/null +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -0,0 +1,30 @@ +.dot { + position: absolute; + left: var(--left); + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color); + border: 1px solid rgba(255, 255, 255, 0.15); + cursor: pointer; + transition: + width 0.15s ease, + height 0.15s ease, + box-shadow 0.15s ease; + z-index: 1; +} + +.dot:hover { + width: 13px; + height: 13px; +} + +.selected { + width: 14px; + height: 14px; + border: 2px solid #fff; + box-shadow: 0 0 8px var(--color); + z-index: 10; +} diff --git a/frontend/src/features/timeline/styles/EventPanel.module.css b/frontend/src/features/timeline/styles/EventPanel.module.css new file mode 100644 index 000000000..044bb9c90 --- /dev/null +++ b/frontend/src/features/timeline/styles/EventPanel.module.css @@ -0,0 +1,79 @@ +.panel { + position: fixed; + right: 2rem; + top: 50%; + transform: translateY(-50%); + width: 320px; + background: #0f0f1a; + border: 1px solid color-mix(in srgb, var(--color) 25%, transparent); + border-left: 3px solid var(--color); + padding: 1.75rem; + z-index: 100; + box-shadow: 0 0 60px rgba(0, 0, 0, 0.7); +} + +.closeBtn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: none; + border: none; + color: #444; + cursor: pointer; + font-size: 0.9rem; + transition: color 0.15s ease; + padding: 0; +} + +.closeBtn:hover { + color: #e8e4d9; +} + +.category { + font-size: 0.65rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color); + margin-bottom: 0.5rem; + font-family: "JetBrains Mono", monospace; +} + +.title { + margin: 0 0 0.5rem; + font-family: "Cormorant Garamond", serif; + font-size: 1.1rem; + font-weight: 400; + line-height: 1.3; + color: #e8e4d9; +} + +.meta { + font-size: 0.75rem; + color: #555; + margin-bottom: 1rem; + font-family: "JetBrains Mono", monospace; +} + +.summary { + font-size: 0.85rem; + color: #888; + line-height: 1.7; + margin: 0 0 1.25rem; +} + +.sources { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.sourceLink { + font-size: 0.75rem; + color: var(--color); + text-decoration: none; + transition: opacity 0.15s ease; +} + +.sourceLink:hover { + opacity: 0.7; +} diff --git a/frontend/src/features/timeline/styles/LayerSelector.module.css b/frontend/src/features/timeline/styles/LayerSelector.module.css new file mode 100644 index 000000000..fda0e7584 --- /dev/null +++ b/frontend/src/features/timeline/styles/LayerSelector.module.css @@ -0,0 +1,31 @@ +.wrapper { + display: flex; + gap: 0.75rem; + margin-bottom: 2.5rem; + flex-wrap: wrap; +} + +.btn { + padding: 0.4rem 1.25rem; + background: transparent; + border: 1px solid #2a2a3e; + color: #444; + border-radius: 2px; + cursor: pointer; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: all 0.15s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.active { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); + color: var(--accent); +} diff --git a/frontend/src/features/timeline/styles/TimelinePage.module.css b/frontend/src/features/timeline/styles/TimelinePage.module.css new file mode 100644 index 000000000..27a21d1e6 --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelinePage.module.css @@ -0,0 +1,42 @@ +.page { + padding: 3rem 2.5rem; + min-height: 100vh; + background: #0a0a0f; +} + +.header { + margin-bottom: 3rem; +} + +.title { + font-family: "Cormorant Garamond", serif; + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: 300; + letter-spacing: 0.05em; + margin: 0 0 0.4rem; + color: #e8e4d9; +} + +.subtitle { + color: #333; + font-size: 0.8rem; + font-family: "JetBrains Mono", monospace; + margin: 0; + letter-spacing: 0.1em; +} + +.timeline { + position: relative; +} + +.status { + color: #333; + font-family: "JetBrains Mono", monospace; + font-size: 0.8rem; +} + +.statusError { + color: #c0392b; + font-family: "JetBrains Mono", monospace; + font-size: 0.8rem; +} diff --git a/frontend/src/features/timeline/styles/TimelineRow.module.css b/frontend/src/features/timeline/styles/TimelineRow.module.css new file mode 100644 index 000000000..5f2e9cf8d --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelineRow.module.css @@ -0,0 +1,36 @@ +.wrapper { + margin-bottom: 2rem; +} + +.label { + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 0.5rem; + font-family: "JetBrains Mono", monospace; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.count { + color: #333; +} + +.track { + position: relative; + height: 48px; + background: #0f0f1a; + border: 1px solid #1a1a2e; + border-radius: 2px; +} + +.centerLine { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: #1a1a2e; +} diff --git a/frontend/src/features/timeline/styles/YearAxis.module.css b/frontend/src/features/timeline/styles/YearAxis.module.css new file mode 100644 index 000000000..ac82a660f --- /dev/null +++ b/frontend/src/features/timeline/styles/YearAxis.module.css @@ -0,0 +1,15 @@ +.axis { + position: relative; + height: 24px; + margin-top: 0.75rem; +} + +.tick { + position: absolute; + left: var(--left); + transform: translateX(-50%); + font-size: 0.65rem; + color: #333; + font-family: "JetBrains Mono", monospace; + user-select: none; +} diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx new file mode 100644 index 000000000..775d8e096 --- /dev/null +++ b/frontend/src/layouts/AppLayout.jsx @@ -0,0 +1,41 @@ +// frontend/src/layouts/AppLayout.jsx + +import { Outlet, Link } from "react-router-dom"; +import { useAuthStore } from "../stores/authStore"; +import styles from "./AppLayout.module.css"; + +export default function AppLayout() { + const { user, logout, isAuthenticated } = useAuthStore(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/layouts/AppLayout.module.css b/frontend/src/layouts/AppLayout.module.css new file mode 100644 index 000000000..7c2366231 --- /dev/null +++ b/frontend/src/layouts/AppLayout.module.css @@ -0,0 +1,71 @@ +@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500&family=JetBrains+Mono:wght@300;400&display=swap"); + +.root { + min-height: 100vh; + background: #0a0a0f; + color: #e8e4d9; + font-family: "JetBrains Mono", monospace; +} + +.nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 2.5rem; + border-bottom: 1px solid #1a1a2e; +} + +.logo { + font-family: "Cormorant Garamond", serif; + font-size: 1.3rem; + font-weight: 300; + letter-spacing: 0.2em; + color: #e8e4d9; + text-decoration: none; + text-transform: uppercase; +} + +.navLinks { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.navLink { + color: #555; + text-decoration: none; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: color 0.15s ease; +} + +.navLink:hover { + color: #e8e4d9; +} + +.userEmail { + color: #333; + font-size: 0.75rem; +} + +.logoutBtn { + background: none; + border: none; + color: #555; + cursor: pointer; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: color 0.15s ease; + padding: 0; +} + +.logoutBtn:hover { + color: #e8e4d9; +} + +.main { + width: 100%; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..c99005330 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; +import App from "./App.jsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - + , ); diff --git a/frontend/src/pages/AppLayout.jsx b/frontend/src/pages/AppLayout.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index e69de29bb..700d813a2 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,7 @@ +export default function LoginPage() { + return ( +
+

Login

+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index e69de29bb..3c923df27 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -0,0 +1,7 @@ +export default function RegisterPage() { + return ( +
+

Register

+
+ ); +} diff --git a/frontend/src/pages/TimelinePage.jsx b/frontend/src/pages/TimelinePage.jsx deleted file mode 100644 index e69de29bb..000000000 From 3c5248b99752fa24040f6ed0e8e3adffc0e66a54 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 27 Feb 2026 15:12:58 +0100 Subject: [PATCH 14/36] feat(timeline): add pixel-based clustering with explode interaction --- README.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 31466b54c..000000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Final Project - -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. - -## The problem - -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? - -## View it live - -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file From a4ddac5576bdfd5cdd4c3dd623fc9b298bb599ff Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Fri, 27 Feb 2026 16:12:31 +0100 Subject: [PATCH 15/36] feat(timeline): add pixel-based clustering with explode interaction --- backend/api/layers/layers.service.js | 1 - backend/jobs/import/war.job.js | 2 - backend/routes/layersRoutes.js | 9 + .../features/timeline/components/EventDot.jsx | 26 +- .../timeline/components/TimelineRow.jsx | 237 +++++++++++++++++- .../timeline/styles/EventDot.module.css | 32 ++- 6 files changed, 285 insertions(+), 22 deletions(-) diff --git a/backend/api/layers/layers.service.js b/backend/api/layers/layers.service.js index bed88d019..868b14921 100644 --- a/backend/api/layers/layers.service.js +++ b/backend/api/layers/layers.service.js @@ -1,4 +1,3 @@ -// backend/api/layers/layers.service.js import Layer from "../../models/Layer.js"; import Event from "../../models/Event.js"; diff --git a/backend/jobs/import/war.job.js b/backend/jobs/import/war.job.js index 1ae0ead3f..ebf5b665e 100644 --- a/backend/jobs/import/war.job.js +++ b/backend/jobs/import/war.job.js @@ -1,5 +1,3 @@ -// backend/jobs/import/war.job.js - import "dotenv/config"; import Layer from "../../models/Layer.js"; import Event from "../../models/Event.js"; diff --git a/backend/routes/layersRoutes.js b/backend/routes/layersRoutes.js index e69de29bb..f4cc607cf 100644 --- a/backend/routes/layersRoutes.js +++ b/backend/routes/layersRoutes.js @@ -0,0 +1,9 @@ +import express from "express"; +import { listLayers, listLayerEvents } from "./layers.controller.js"; + +const router = express.Router(); + +router.get("/", listLayers); +router.get("/:id/events", listLayerEvents); + +export default router; diff --git a/frontend/src/features/timeline/components/EventDot.jsx b/frontend/src/features/timeline/components/EventDot.jsx index 224b21df5..0c3cf978e 100644 --- a/frontend/src/features/timeline/components/EventDot.jsx +++ b/frontend/src/features/timeline/components/EventDot.jsx @@ -1,19 +1,33 @@ import { CATEGORY_COLORS, dateToPercent } from "../constants"; import styles from "../styles/EventDot.module.css"; -export default function EventDot({ event, onClick, isSelected }) { - const color = CATEGORY_COLORS[event.category] ?? "#888"; - const left = dateToPercent(event.startDate); +export default function EventDot({ + event, + onClick, + isSelected, + offset, + className, + title, + dataCount, + colorOverride, + leftOverride, +}) { + const color = colorOverride ?? CATEGORY_COLORS[event.category] ?? "#888"; + const left = leftOverride ?? dateToPercent(event.startDate); return (
onClick(event)} - title={event.title} + title={title ?? event.title} + data-count={dataCount} style={{ - left, "--color": color, "--left": `${left}%`, + "--dx": offset?.dx ? `${offset.dx}px` : "0px", + "--dy": offset?.dy ? `${offset.dy}px` : "0px", }} /> ); diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index 2e7844d4b..447042af9 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -1,6 +1,22 @@ -import { LAYER_ACCENT } from "../constants"; -import EventDot from "./EventDot"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import dotStyles from "../styles/EventDot.module.css"; import styles from "../styles/TimelineRow.module.css"; +import { LAYER_ACCENT, CATEGORY_COLORS, dateToPercent } from "../constants"; +import EventDot from "./EventDot"; + +const CLUSTER_PX = 10; // hur nära (i px) events måste ligga för att klustras +const EXPLODE_SPACING = 12; // px mellan dots i explode +const EXPLODE_DY = 12; // vertikal offset upp/ner +const MAX_EXPLODE = 24; // säkerhetscap så det inte exploderar 200 dots + +function getClusterColor(events) { + const cats = new Set(events.map((e) => e.category)); + if (cats.size === 1) { + const cat = [...cats][0]; + return CATEGORY_COLORS[cat] ?? "#888"; + } + return "#b0b0b0"; // mixed +} export default function TimelineRow({ layer, @@ -9,6 +25,115 @@ export default function TimelineRow({ onEventClick, }) { const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + const trackRef = useRef(null); + + const [trackWidth, setTrackWidth] = useState(0); + const [explodedKey, setExplodedKey] = useState(null); + + // Measure track width (ResizeObserver) + useEffect(() => { + if (!trackRef.current) return; + + const el = trackRef.current; + + const ro = new ResizeObserver(() => { + const rect = el.getBoundingClientRect(); + setTrackWidth(rect.width); + }); + + ro.observe(el); + + // init + const rect = el.getBoundingClientRect(); + setTrackWidth(rect.width); + + return () => ro.disconnect(); + }, []); + + // Pixel-based clustering (screen proximity) + const clusters = useMemo(() => { + if (!events || events.length === 0) return []; + + // fallback: no clustering until we know width + if (!trackWidth) { + return events.map((e) => ({ + key: e._id, + type: "single", + leftPercent: dateToPercent(e.startDate), + events: [e], + })); + } + + const items = events + .map((e) => { + const leftPercent = dateToPercent(e.startDate); + const xPx = (leftPercent / 100) * trackWidth; + return { e, leftPercent, xPx }; + }) + .sort((a, b) => a.xPx - b.xPx); + + const groups = []; + let group = [items[0]]; + + for (let i = 1; i < items.length; i++) { + const prev = items[i - 1]; + const curr = items[i]; + + if (curr.xPx - prev.xPx <= CLUSTER_PX) { + group.push(curr); + } else { + groups.push(group); + group = [curr]; + } + } + groups.push(group); + + return groups.map((g, idx) => { + const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; + const leftPercent = (leftAvgPx / trackWidth) * 100; + const eventsInCluster = g.map((it) => it.e); + + // stable-ish key: layer + group index + approx px + size + const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${eventsInCluster.length}`; + + return { + key, + type: eventsInCluster.length === 1 ? "single" : "cluster", + leftPercent, + events: eventsInCluster, + }; + }); + }, [events, trackWidth, layer._id]); + + // Close explosion if cluster no longer exists + useEffect(() => { + if (!explodedKey) return; + const stillExists = clusters.some( + (c) => c.key === explodedKey && c.type === "cluster", + ); + if (!stillExists) setExplodedKey(null); + }, [clusters, explodedKey]); + + const toggleExplode = useCallback((key) => { + setExplodedKey((prev) => (prev === key ? null : key)); + }, []); + + // Click outside to close exploded cluster + useEffect(() => { + if (!explodedKey) return; + + const onDocMouseDown = (e) => { + const trackEl = trackRef.current; + if (!trackEl) return; + + if (!trackEl.contains(e.target)) { + setExplodedKey(null); + } + }; + + document.addEventListener("mousedown", onDocMouseDown); + return () => document.removeEventListener("mousedown", onDocMouseDown); + }, [explodedKey]); return (
@@ -16,16 +141,106 @@ export default function TimelineRow({ {layer.name} {events.length} events
-
+ +
- {events.map((event) => ( - - ))} + + {clusters.map((c) => { + if (c.type === "single") { + const event = c.events[0]; + return ( + + ); + } + + const isExploded = explodedKey === c.key; + + // Cluster dot + const clusterColor = getClusterColor(c.events); + + // Exploded items (cap + sort) + const explodedEvents = c.events + .slice() + .sort((a, b) => new Date(a.startDate) - new Date(b.startDate)) + .slice(0, MAX_EXPLODE); + + const hiddenCount = Math.max( + 0, + c.events.length - explodedEvents.length, + ); + + return ( +
+ toggleExplode(c.key)} + isSelected={false} + title={ + isExploded + ? "Click to collapse" + : `${c.events.length} events (click to expand)` + } + dataCount={c.events.length} + colorOverride={clusterColor} + leftOverride={c.leftPercent} + className={dotStyles.cluster} + /> + + {/* Exploded dots */} + {isExploded && + explodedEvents.map((event, i, arr) => { + const mid = (arr.length - 1) / 2; + const dx = Math.round((i - mid) * EXPLODE_SPACING); + const dy = i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY; + + return ( + { + onEventClick(e); + // valfritt: stäng explosion när man väljer ett event + // setExplodedKey(null); + }} + isSelected={selectedEvent?._id === event._id} + offset={{ dx, dy }} + leftOverride={c.leftPercent} + /> + ); + })} + + {/* Optional: “+N more” hint (om cap slår in) */} + {isExploded && hiddenCount > 0 && ( + {}} + isSelected={false} + title={`+${hiddenCount} more (increase MAX_EXPLODE to show)`} + dataCount={`+${hiddenCount}`} + colorOverride={"#777"} + leftOverride={c.leftPercent} + offset={{ dx: 0, dy: -28 }} + className={dotStyles.cluster} + /> + )} +
+ ); + })}
); diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css index 482bf0e8d..2f265a42a 100644 --- a/frontend/src/features/timeline/styles/EventDot.module.css +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -2,7 +2,7 @@ position: absolute; left: var(--left); top: 50%; - transform: translate(-50%, -50%); + transform: translate(-50%, -50%) translate(var(--dx, 0px), var(--dy, 0px)); width: 10px; height: 10px; border-radius: 50%; @@ -12,7 +12,8 @@ transition: width 0.15s ease, height 0.15s ease, - box-shadow 0.15s ease; + box-shadow 0.15s ease, + transform 0.15s ease; z-index: 1; } @@ -28,3 +29,30 @@ box-shadow: 0 0 8px var(--color); z-index: 10; } + +/* cluster-dot style */ +.cluster { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.35); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.35); + z-index: 5; +} + +/* count badge on cluster */ +.cluster::after { + content: attr(data-count); + position: absolute; + top: -14px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + line-height: 1; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #111; + border: 1px solid rgba(0, 0, 0, 0.25); + pointer-events: none; + white-space: nowrap; +} From 41e4a1253d643075f59f6f2e663f32cf42c3859b Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Mon, 2 Mar 2026 13:03:16 +0100 Subject: [PATCH 16/36] feat(medicine): split import into 6 category-specific Wikidata queries - Separate Wikidata queries per medicine subcategory - Remove label-based category guessing - Assign fixed category per import task - Improve data clarity and pedagogical structure --- .../wikidata/mappers/_mapperUtils.js | 2 +- .../wikidata/mappers/medicine.mapper.js | 4 +- .../wikidata/mappers/war.mapper.js | 39 ++--- .../wikidata/queries/medicine/_shared.js | 69 +++++++++ .../queries/medicine/epidemics.query.js | 37 +++++ .../queries/medicine/germTheory.query.js | 34 +++++ .../queries/medicine/hospitals.query.js | 43 ++++++ .../medicine/medicalDiscoveries.query.js | 40 ++++++ .../queries/{ => medicine}/medicine.query.js | 16 +-- .../queries/medicine/publicHealth.query.js | 34 +++++ .../queries/medicine/vaccines.query.js | 33 +++++ .../wikidata/queries/war.query.js | 9 +- backend/jobs/import/medicine.job.js | 133 +++++++++++++++--- .../timeline/components/TimelineRow.jsx | 120 ++++++++-------- 14 files changed, 486 insertions(+), 127 deletions(-) create mode 100644 backend/integrations/wikidata/queries/medicine/_shared.js create mode 100644 backend/integrations/wikidata/queries/medicine/epidemics.query.js create mode 100644 backend/integrations/wikidata/queries/medicine/germTheory.query.js create mode 100644 backend/integrations/wikidata/queries/medicine/hospitals.query.js create mode 100644 backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js rename backend/integrations/wikidata/queries/{ => medicine}/medicine.query.js (83%) create mode 100644 backend/integrations/wikidata/queries/medicine/publicHealth.query.js create mode 100644 backend/integrations/wikidata/queries/medicine/vaccines.query.js diff --git a/backend/integrations/wikidata/mappers/_mapperUtils.js b/backend/integrations/wikidata/mappers/_mapperUtils.js index 7c35a1ae3..294796424 100644 --- a/backend/integrations/wikidata/mappers/_mapperUtils.js +++ b/backend/integrations/wikidata/mappers/_mapperUtils.js @@ -48,7 +48,7 @@ export function buildEventDoc(row, layerId, mapCategory) { summary: row.eventDescription?.value || null, startDate, endDate: endDate || null, - category: mapCategory(row.instanceLabel?.value), + category: mapCategory(row), tags: [], location, sources, diff --git a/backend/integrations/wikidata/mappers/medicine.mapper.js b/backend/integrations/wikidata/mappers/medicine.mapper.js index ad711a9f3..af3dd870d 100644 --- a/backend/integrations/wikidata/mappers/medicine.mapper.js +++ b/backend/integrations/wikidata/mappers/medicine.mapper.js @@ -1,5 +1,3 @@ -// backend/integrations/wikidata/mappers/medicine.mapper.js - import { buildEventDoc } from "./_mapperUtils.js"; function mapCategory(instanceLabel) { @@ -39,5 +37,5 @@ function mapCategory(instanceLabel) { } export default function mapMedicineEvent(row, layerId) { - return buildEventDoc(row, layerId, mapCategory); + return buildEventDoc(row, layerId, (r) => mapCategory(r?.typeLabel?.value)); } diff --git a/backend/integrations/wikidata/mappers/war.mapper.js b/backend/integrations/wikidata/mappers/war.mapper.js index 45810d307..eba433e75 100644 --- a/backend/integrations/wikidata/mappers/war.mapper.js +++ b/backend/integrations/wikidata/mappers/war.mapper.js @@ -1,36 +1,27 @@ -import { buildEventDoc } from "./_mapperUtils.js"; +import { buildEventDoc, extractQid } from "./_mapperUtils.js"; /** - * Maps a Wikidata instance label to a war layer category slug. + * Maps a Wikidata type QID (from VALUES ?type) to a war layer category slug. * - * @param {string} instanceLabel - Instance type label from Wikidata (e.g. "civil war", "genocide") - * @returns {string} Category slug + * @param {string} typeUri + * @returns {string} */ -function mapCategory(instanceLabel) { - if (!instanceLabel) return "interstate_wars"; - const label = instanceLabel.toLowerCase(); +function mapCategoryFromTypeUri(typeUri) { + if (!typeUri) return "interstate_wars"; + const qid = extractQid(typeUri); // "Q8465" - if (label.includes("civil war")) return "civil_wars"; - if (label.includes("genocide") || label.includes("massacre")) + if (qid === "Q8465") return "civil_wars"; + if (qid === "Q13418847" || qid === "Q188055") return "genocides_mass_violence"; - if ( - label.includes("revolution") || - label.includes("uprising") || - label.includes("revolt") || - label.includes("rebellion") - ) - return "revolutions_uprisings"; + if (qid === "Q152786" || qid === "Q467011") return "revolutions_uprisings"; return "interstate_wars"; } -/** - * Maps a Wikidata SPARQL result row to a war Event document. - * - * @param {Object} row - SPARQL result row from Wikidata - * @param {string} layerId - MongoDB ObjectId for the war layer - * @returns {Object|null} Event document, or null if the row is invalid - */ export default function mapWarEvent(row, layerId) { - return buildEventDoc(row, layerId, mapCategory); + // buildEventDoc will call mapCategory(...) with whatever is passed it + // so we pass a function that reads row.type.value + return buildEventDoc(row, layerId, () => + mapCategoryFromTypeUri(row?.type?.value), + ); } diff --git a/backend/integrations/wikidata/queries/medicine/_shared.js b/backend/integrations/wikidata/queries/medicine/_shared.js new file mode 100644 index 000000000..81115c2e3 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/_shared.js @@ -0,0 +1,69 @@ +export function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +export function basePrefixes() { + return ` +PREFIX xsd: +PREFIX schema: + `.trim(); +} + +export function baseSelect() { + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?type ?typeLabel + ?article +WHERE { + `.trim(); +} + +export function baseEuropeFilter() { + return ` + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + `.trim(); +} + +export function baseDates(from, to) { + return ` + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} + +export function baseWikipedia() { + return ` + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/epidemics.query.js b/backend/integrations/wikidata/queries/medicine/epidemics.query.js new file mode 100644 index 000000000..901af90ec --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/epidemics.query.js @@ -0,0 +1,37 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildEpidemicsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q44512 # epidemic + wd:Q1516910 # plague epidemic + wd:Q2723958 # influenza pandemic + wd:Q178561 # pandemic + wd:Q1369832 # disease outbreak + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + # Exclude military conflicts (safety) + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/germTheory.query.js b/backend/integrations/wikidata/queries/medicine/germTheory.query.js new file mode 100644 index 000000000..0cc58314f --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/germTheory.query.js @@ -0,0 +1,34 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildGermTheoryQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q13442814 # germ theory of disease + wd:Q79948 # bacteriology + wd:Q7944 # microbiology + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/hospitals.query.js b/backend/integrations/wikidata/queries/medicine/hospitals.query.js new file mode 100644 index 000000000..032a0a173 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/hospitals.query.js @@ -0,0 +1,43 @@ +import { + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildHospitalsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q16917 # hospital + wd:Q1774898 # clinic + } + + ?event wdt:P31 ?type . + + { + ?event wdt:P571 ?startDate . # inception for institutions + } UNION { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js b/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js new file mode 100644 index 000000000..1f694a668 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js @@ -0,0 +1,40 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +/** + * Fetches major medical discoveries and procedures in Europe + * within a given date range. + * + * Category: medical_breakthroughs + */ +export default function buildMedicalDiscoveriesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q7314688 # medical discovery + wd:Q796194 # medical procedure + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + # Safety: exclude military-related entities + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine.query.js b/backend/integrations/wikidata/queries/medicine/medicine.query.js similarity index 83% rename from backend/integrations/wikidata/queries/medicine.query.js rename to backend/integrations/wikidata/queries/medicine/medicine.query.js index ecd475674..262f45496 100644 --- a/backend/integrations/wikidata/queries/medicine.query.js +++ b/backend/integrations/wikidata/queries/medicine/medicine.query.js @@ -1,21 +1,16 @@ -/** - * Returns a SPARQL query for fetching medicine and disease events in Europe - * from Wikidata within a given date range. - * - * @param {Date} rangeStart - * @param {Date} rangeEnd - * @returns {string} SPARQL query - */ export default function buildMedicineQuery(rangeStart, rangeEnd) { const from = rangeStart.toISOString().split("T")[0]; const to = rangeEnd.toISOString().split("T")[0]; return ` +PREFIX xsd: +PREFIX schema: + SELECT DISTINCT ?event ?eventLabel ?eventDescription ?startDate ?endDate ?countryLabel ?locationLabel - ?instance ?instanceLabel + ?type ?typeLabel ?article WHERE { VALUES ?type { @@ -28,7 +23,7 @@ WHERE { wd:Q796194 # medical procedure wd:Q7314688 # medical discovery } - + ?event wdt:P31 ?type . { @@ -56,7 +51,6 @@ WHERE { OPTIONAL { ?event wdt:P582 ?endDate . } OPTIONAL { ?event wdt:P17 ?country . } OPTIONAL { ?event wdt:P276 ?location . } - OPTIONAL { ?event wdt:P31 ?instance . } OPTIONAL { ?article schema:about ?event . diff --git a/backend/integrations/wikidata/queries/medicine/publicHealth.query.js b/backend/integrations/wikidata/queries/medicine/publicHealth.query.js new file mode 100644 index 000000000..448e625bb --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/publicHealth.query.js @@ -0,0 +1,34 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildPublicHealthQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q189533 # public health + wd:Q284465 # sanitation + wd:Q133500 # hygiene + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/vaccines.query.js b/backend/integrations/wikidata/queries/medicine/vaccines.query.js new file mode 100644 index 000000000..e63075925 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/vaccines.query.js @@ -0,0 +1,33 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildVaccinesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q11461 # vaccine + wd:Q134808 # vaccination (sometimes used) + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war.query.js b/backend/integrations/wikidata/queries/war.query.js index b06402b54..4a3337afe 100644 --- a/backend/integrations/wikidata/queries/war.query.js +++ b/backend/integrations/wikidata/queries/war.query.js @@ -1,5 +1,3 @@ -// backend/integrations/wikidata/queries/war.query.js - /** * Returns a SPARQL query for fetching wars and organized violence in Europe * from Wikidata within a given date range. @@ -13,11 +11,15 @@ export default function buildWarQuery(rangeStart, rangeEnd) { const to = rangeEnd.toISOString().split("T")[0]; return ` + + PREFIX xsd: + PREFIX schema: + SELECT DISTINCT ?event ?eventLabel ?eventDescription ?startDate ?endDate ?countryLabel ?locationLabel - ?instance ?instanceLabel + ?type ?article WHERE { VALUES ?type { @@ -49,7 +51,6 @@ WHERE { OPTIONAL { ?event wdt:P582 ?endDate . } OPTIONAL { ?event wdt:P17 ?country . } OPTIONAL { ?event wdt:P276 ?location . } - OPTIONAL { ?event wdt:P31 ?instance . } OPTIONAL { ?article schema:about ?event . diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js index 5a0631aeb..6e53f57d4 100644 --- a/backend/jobs/import/medicine.job.js +++ b/backend/jobs/import/medicine.job.js @@ -1,8 +1,15 @@ import "dotenv/config"; import Layer from "../../models/Layer.js"; import Event from "../../models/Event.js"; -import buildMedicineQuery from "../../integrations/wikidata/queries/medicine.query.js"; -import mapMedicineEvent from "../../integrations/wikidata/mappers/medicine.mapper.js"; + +import buildEpidemicsQuery from "../../integrations/wikidata/queries/medicine/epidemics.query.js"; +import buildVaccinesQuery from "../../integrations/wikidata/queries/medicine/vaccines.query.js"; +import buildMedicalDiscoveriesQuery from "../../integrations/wikidata/queries/medicine/medicalDiscoveries.query.js"; +import buildPublicHealthQuery from "../../integrations/wikidata/queries/medicine/publicHealth.query.js"; +import buildHospitalsQuery from "../../integrations/wikidata/queries/medicine/hospitals.query.js"; +import buildGermTheoryQuery from "../../integrations/wikidata/queries/medicine/germTheory.query.js"; + +import { buildEventDoc } from "../../integrations/wikidata/mappers/_mapperUtils.js"; import { startFromCLI } from "./_runImport.js"; const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; @@ -37,38 +44,45 @@ async function fetchFromWikidata(sparql) { return data.results.bindings; } +function makeFixedCategoryMapper(category) { + return (row, layerId) => buildEventDoc(row, layerId, () => category); +} + /** - * Runs the medicine import job. - * Fetches events from Wikidata and upserts them into MongoDB. - * - * @param {{ dryRun?: boolean }} options - * @returns {Promise} Import summary + * Runs one category import (one query). */ -export async function runMedicineImport({ dryRun = false } = {}) { - const layer = await Layer.findOne({ slug: "medicine_disease_europe" }).lean(); - if (!layer) - throw new Error("Medicine layer not found. Have you run the seed?"); - - console.log(`Layer: ${layer.name} (${layer._id})`); +async function importCategory({ layer, category, buildQuery, dryRun }) { + console.log(`\n→ Importing category: ${category}`); - const sparql = buildMedicineQuery(layer.rangeStart, layer.rangeEnd); + const sparql = buildQuery(layer.rangeStart, layer.rangeEnd); const rows = await fetchFromWikidata(sparql); console.log(`Wikidata returned ${rows.length} rows`); - const docs = rows - .map((row) => mapMedicineEvent(row, layer._id)) - .filter(Boolean); + const mapRow = makeFixedCategoryMapper(category); + const docs = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + console.log( `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, ); if (dryRun) { - console.log("Sample (first 3):", JSON.stringify(docs.slice(0, 3), null, 2)); + console.log("Sample (first 2):", JSON.stringify(docs.slice(0, 2), null, 2)); return { + category, total: rows.length, mapped: docs.length, upserted: 0, - dryRun: true, + modified: 0, + }; + } + + if (docs.length === 0) { + return { + category, + total: rows.length, + mapped: 0, + upserted: 0, + modified: 0, }; } @@ -85,16 +99,89 @@ export async function runMedicineImport({ dryRun = false } = {}) { const result = await Event.bulkWrite(ops, { ordered: false }); - const summary = { + return { + category, total: rows.length, mapped: docs.length, upserted: result.upsertedCount, modified: result.modifiedCount, - dryRun: false, + }; +} + +/** + * Runs the medicine import job (6 separate queries). + * + * @param {{ dryRun?: boolean }} options + * @returns {Promise} Import summary + */ +export async function runMedicineImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ slug: "medicine_disease_europe" }).lean(); + if (!layer) + throw new Error("Medicine layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + console.log( + `Range: ${layer.rangeStart.toISOString()} → ${layer.rangeEnd.toISOString()}`, + ); + + const tasks = [ + { + category: "major_epidemics_pandemics", + buildQuery: buildEpidemicsQuery, + }, + { + category: "vaccines", + buildQuery: buildVaccinesQuery, + }, + { + category: "medical_breakthroughs", + buildQuery: buildMedicalDiscoveriesQuery, + }, + { + category: "public_health_reforms", + buildQuery: buildPublicHealthQuery, + }, + { + category: "hospital_systems", + buildQuery: buildHospitalsQuery, + }, + { + category: "germ_theory_bacteriology", + buildQuery: buildGermTheoryQuery, + }, + ]; + + const results = []; + for (const t of tasks) { + // sequential is kinder to Wikidata + easier to read logs + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } + + const summary = results.reduce( + (acc, r) => { + acc.total += r.total; + acc.mapped += r.mapped; + acc.upserted += r.upserted; + acc.modified += r.modified; + return acc; + }, + { total: 0, mapped: 0, upserted: 0, modified: 0 }, + ); + + const full = { + ...summary, + perCategory: results, + dryRun, }; - console.log("Import complete:", summary); - return summary; + console.log("\nImport complete:", full); + return full; } // CLI entry point diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index 447042af9..89606e69b 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -4,13 +4,13 @@ import styles from "../styles/TimelineRow.module.css"; import { LAYER_ACCENT, CATEGORY_COLORS, dateToPercent } from "../constants"; import EventDot from "./EventDot"; -const CLUSTER_PX = 10; // hur nära (i px) events måste ligga för att klustras -const EXPLODE_SPACING = 12; // px mellan dots i explode -const EXPLODE_DY = 12; // vertikal offset upp/ner -const MAX_EXPLODE = 24; // säkerhetscap så det inte exploderar 200 dots +const CLUSTER_PX = 8; // lite tightare än 10 för att undvika mega-clusters +const EXPLODE_DY = 12; // vertikalt varannan upp/ner +const EXPLODE_DX = 10; // bara om flera har exakt samma x +const MAX_EXPLODE = 60; // visa fler i explode (valfritt) function getClusterColor(events) { - const cats = new Set(events.map((e) => e.category)); + const cats = new Set(events.map((x) => x.event.category)); if (cats.size === 1) { const cat = [...cats][0]; return CATEGORY_COLORS[cat] ?? "#888"; @@ -30,37 +30,32 @@ export default function TimelineRow({ const [trackWidth, setTrackWidth] = useState(0); const [explodedKey, setExplodedKey] = useState(null); - // Measure track width (ResizeObserver) + // Measure track width useEffect(() => { if (!trackRef.current) return; const el = trackRef.current; - const ro = new ResizeObserver(() => { - const rect = el.getBoundingClientRect(); - setTrackWidth(rect.width); + setTrackWidth(el.getBoundingClientRect().width); }); ro.observe(el); - - // init - const rect = el.getBoundingClientRect(); - setTrackWidth(rect.width); + setTrackWidth(el.getBoundingClientRect().width); return () => ro.disconnect(); }, []); - // Pixel-based clustering (screen proximity) + // Build pixel-based clusters WITHOUT chain-merging const clusters = useMemo(() => { if (!events || events.length === 0) return []; - // fallback: no clustering until we know width + // If we can't measure yet, render singles (no clustering) to avoid weirdness if (!trackWidth) { return events.map((e) => ({ key: e._id, type: "single", leftPercent: dateToPercent(e.startDate), - events: [e], + items: [{ event: e, leftPercent: dateToPercent(e.startDate) }], })); } @@ -68,22 +63,24 @@ export default function TimelineRow({ .map((e) => { const leftPercent = dateToPercent(e.startDate); const xPx = (leftPercent / 100) * trackWidth; - return { e, leftPercent, xPx }; + return { event: e, leftPercent, xPx }; }) .sort((a, b) => a.xPx - b.xPx); const groups = []; let group = [items[0]]; + let groupStartX = items[0].xPx; for (let i = 1; i < items.length; i++) { - const prev = items[i - 1]; const curr = items[i]; - if (curr.xPx - prev.xPx <= CLUSTER_PX) { + // IMPORTANT: compare to groupStartX to avoid chain effect + if (curr.xPx - groupStartX <= CLUSTER_PX) { group.push(curr); } else { groups.push(group); group = [curr]; + groupStartX = curr.xPx; } } groups.push(group); @@ -91,21 +88,19 @@ export default function TimelineRow({ return groups.map((g, idx) => { const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; const leftPercent = (leftAvgPx / trackWidth) * 100; - const eventsInCluster = g.map((it) => it.e); - // stable-ish key: layer + group index + approx px + size - const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${eventsInCluster.length}`; + const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${g.length}`; return { key, - type: eventsInCluster.length === 1 ? "single" : "cluster", + type: g.length === 1 ? "single" : "cluster", leftPercent, - events: eventsInCluster, + items: g, // [{event,leftPercent,xPx}] }; }); }, [events, trackWidth, layer._id]); - // Close explosion if cluster no longer exists + // Close explosion if cluster disappears useEffect(() => { if (!explodedKey) return; const stillExists = clusters.some( @@ -118,17 +113,14 @@ export default function TimelineRow({ setExplodedKey((prev) => (prev === key ? null : key)); }, []); - // Click outside to close exploded cluster + // Click outside closes explosion useEffect(() => { if (!explodedKey) return; const onDocMouseDown = (e) => { const trackEl = trackRef.current; if (!trackEl) return; - - if (!trackEl.contains(e.target)) { - setExplodedKey(null); - } + if (!trackEl.contains(e.target)) setExplodedKey(null); }; document.addEventListener("mousedown", onDocMouseDown); @@ -147,40 +139,39 @@ export default function TimelineRow({ {clusters.map((c) => { if (c.type === "single") { - const event = c.events[0]; + const it = c.items[0]; return ( ); } const isExploded = explodedKey === c.key; - // Cluster dot - const clusterColor = getClusterColor(c.events); + const clusterColor = getClusterColor(c.items); + const count = c.items.length; - // Exploded items (cap + sort) - const explodedEvents = c.events + // sort for explode + const explodedItems = c.items .slice() - .sort((a, b) => new Date(a.startDate) - new Date(b.startDate)) + .sort((a, b) => a.xPx - b.xPx) .slice(0, MAX_EXPLODE); - const hiddenCount = Math.max( - 0, - c.events.length - explodedEvents.length, - ); + const hiddenCount = Math.max(0, count - explodedItems.length); + // cluster dot return (
toggleExplode(c.key)} @@ -188,38 +179,45 @@ export default function TimelineRow({ title={ isExploded ? "Click to collapse" - : `${c.events.length} events (click to expand)` + : `${count} events (click to expand)` } - dataCount={c.events.length} + dataCount={count} colorOverride={clusterColor} leftOverride={c.leftPercent} className={dotStyles.cluster} /> - {/* Exploded dots */} + {/* exploded dots: show on their REAL year */} {isExploded && - explodedEvents.map((event, i, arr) => { - const mid = (arr.length - 1) / 2; - const dx = Math.round((i - mid) * EXPLODE_SPACING); + explodedItems.map((it, i) => { + // If multiple events share the same xPx (same pixel), nudge sideways. + // Group identical xPx into small stacks: + const sameX = explodedItems.filter( + (x) => Math.abs(x.xPx - it.xPx) < 0.5, + ); + let dx = 0; + if (sameX.length > 1) { + const j = sameX.findIndex( + (x) => x.event._id === it.event._id, + ); + const mid = (sameX.length - 1) / 2; + dx = Math.round((j - mid) * EXPLODE_DX); + } + const dy = i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY; return ( { - onEventClick(e); - // valfritt: stäng explosion när man väljer ett event - // setExplodedKey(null); - }} - isSelected={selectedEvent?._id === event._id} + key={it.event._id} + event={it.event} + onClick={onEventClick} + isSelected={selectedEvent?._id === it.event._id} + leftOverride={it.leftPercent} offset={{ dx, dy }} - leftOverride={c.leftPercent} /> ); })} - {/* Optional: “+N more” hint (om cap slår in) */} {isExploded && hiddenCount > 0 && ( Date: Mon, 2 Mar 2026 14:05:26 +0100 Subject: [PATCH 17/36] feat(war): split import to 5 category-specific Wikidata queries --- .../wikidata/queries/war/_shared.js | 82 ++++++++++ .../wikidata/queries/war/civilWars.query.js | 30 ++++ .../war/genocidesMassViolence.query.js | 40 +++++ .../queries/war/interstateWars.query.js | 38 +++++ .../queries/war/militaryAlliances.query.js | 27 ++++ .../queries/war/revolutionsUprisings.query.js | 31 ++++ backend/jobs/import/war.job.js | 147 ++++++++++++++---- 7 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 backend/integrations/wikidata/queries/war/_shared.js create mode 100644 backend/integrations/wikidata/queries/war/civilWars.query.js create mode 100644 backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js create mode 100644 backend/integrations/wikidata/queries/war/interstateWars.query.js create mode 100644 backend/integrations/wikidata/queries/war/militaryAlliances.query.js create mode 100644 backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js diff --git a/backend/integrations/wikidata/queries/war/_shared.js b/backend/integrations/wikidata/queries/war/_shared.js new file mode 100644 index 000000000..9bc0b6c02 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/_shared.js @@ -0,0 +1,82 @@ +export function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +export function prefixes() { + return ` +PREFIX xsd: +PREFIX schema: + `.trim(); +} + +export function selectBase() { + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?article +WHERE { + `.trim(); +} + +export function europeFilter() { + return ` + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + `.trim(); +} + +export function dateFilterP580(from, to) { + return ` + ?event wdt:P580 ?startDate . + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} + +export function dateFilterAlliance(from, to) { + // Alliances/treaties often use inception (P571) or point-in-time (P585) instead of P580. + return ` + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P571 ?startDate . + } UNION { + ?event wdt:P585 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} + +export function wikipediaAndLabels() { + return ` + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/civilWars.query.js b/backend/integrations/wikidata/queries/war/civilWars.query.js new file mode 100644 index 000000000..c936ec9ca --- /dev/null +++ b/backend/integrations/wikidata/queries/war/civilWars.query.js @@ -0,0 +1,30 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildCivilWarsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q8465 # civil war + } + + ?event wdt:P31 ?type . + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js b/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js new file mode 100644 index 000000000..1e36c6727 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js @@ -0,0 +1,40 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildGenocidesMassViolenceQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q13418847 # genocide + } + + ?event wdt:P31 ?type . + + # Guard rails: keep this bucket pedagogically clean + FILTER NOT EXISTS { ?event wdt:P31 wd:Q152786 } # revolution + FILTER NOT EXISTS { ?event wdt:P31 wd:Q467011 } # rebellion + FILTER NOT EXISTS { ?event wdt:P31 wd:Q8465 } # civil war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q198 } # war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q1261499 } # military conflict + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} + +// NOTE:- massacre (wd:Q188055) intentionally excluded due to noise +// wd:Q188055 # massacre- picks up on the wrong things, possibly add “ethnic cleansing” later move massacre out of war (or make it opt-in later) diff --git a/backend/integrations/wikidata/queries/war/interstateWars.query.js b/backend/integrations/wikidata/queries/war/interstateWars.query.js new file mode 100644 index 000000000..1aab0c6cb --- /dev/null +++ b/backend/integrations/wikidata/queries/war/interstateWars.query.js @@ -0,0 +1,38 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildInterstateWarsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q198 # war + wd:Q1261499 # military conflict + } + + ?event wdt:P31 ?type . + + # Exclude categories handled by other queries + FILTER NOT EXISTS { ?event wdt:P31 wd:Q8465 } # civil war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q152786 } # revolution + FILTER NOT EXISTS { ?event wdt:P31 wd:Q467011 } # rebellion + FILTER NOT EXISTS { ?event wdt:P31 wd:Q13418847 } # genocide + FILTER NOT EXISTS { ?event wdt:P31 wd:Q188055 } # massacre + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/militaryAlliances.query.js b/backend/integrations/wikidata/queries/war/militaryAlliances.query.js new file mode 100644 index 000000000..2bd9f0773 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/militaryAlliances.query.js @@ -0,0 +1,27 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterAlliance, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildMilitaryAlliancesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + # military alliance (includes subclasses) + ?event wdt:P31/wdt:P279* wd:Q1127126 . + +${dateFilterAlliance(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js b/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js new file mode 100644 index 000000000..edcf8c23b --- /dev/null +++ b/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js @@ -0,0 +1,31 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildRevolutionsUprisingsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q152786 # revolution + wd:Q467011 # rebellion + } + + ?event wdt:P31 ?type . + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/jobs/import/war.job.js b/backend/jobs/import/war.job.js index ebf5b665e..d02596c55 100644 --- a/backend/jobs/import/war.job.js +++ b/backend/jobs/import/war.job.js @@ -1,20 +1,20 @@ import "dotenv/config"; import Layer from "../../models/Layer.js"; import Event from "../../models/Event.js"; -import buildWarQuery from "../../integrations/wikidata/queries/war.query.js"; -import mapWarEvent from "../../integrations/wikidata/mappers/war.mapper.js"; -import { runImport, startFromCLI } from "./_runImport.js"; + +import buildInterstateWarsQuery from "../../integrations/wikidata/queries/war/interstateWars.query.js"; +import buildCivilWarsQuery from "../../integrations/wikidata/queries/war/civilWars.query.js"; +import buildRevolutionsUprisingsQuery from "../../integrations/wikidata/queries/war/revolutionsUprisings.query.js"; +import buildGenocidesMassViolenceQuery from "../../integrations/wikidata/queries/war/genocidesMassViolence.query.js"; +import buildMilitaryAlliancesQuery from "../../integrations/wikidata/queries/war/militaryAlliances.query.js"; + +import { buildEventDoc } from "../../integrations/wikidata/mappers/_mapperUtils.js"; +import { startFromCLI } from "./_runImport.js"; const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; const USER_AGENT = "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; -/** - * Fetches raw SPARQL results from Wikidata. - * - * @param {string} sparql - * @returns {Promise} - */ async function fetchFromWikidata(sparql) { const url = new URL(WIKIDATA_ENDPOINT); url.searchParams.set("query", sparql); @@ -37,37 +37,72 @@ async function fetchFromWikidata(sparql) { return data.results.bindings; } -/** - * Runs the war import job. - * Fetches events from Wikidata and upserts them into MongoDB. - * - * @param {{ dryRun?: boolean }} options - * @returns {Promise} Import summary - */ -export async function runWarImport({ dryRun = false } = {}) { - const layer = await Layer.findOne({ - slug: "war_organized_violence_europe", - }).lean(); - if (!layer) throw new Error("War layer not found. Have you run the seed?"); +// Helper to get rid of duplicates. In some cases, the same event is returned multiple times from Wikidata with the same QID, because of multiple types or other reasons. This function keeps only one doc per Wikidata QID, preferring docs with a location if there are duplicates. - console.log(`Layer: ${layer.name} (${layer._id})`); +function dedupeByWikidataQid(docs) { + const map = new Map(); + + for (const doc of docs) { + const qid = doc?.externalIds?.wikidataQid; + if (!qid) continue; - const sparql = buildWarQuery(layer.rangeStart, layer.rangeEnd); + const existing = map.get(qid); + if (!existing) { + map.set(qid, doc); + continue; + } + + // more specific location if duplicate + const existingHasLocation = !!existing.location; + const docHasLocation = !!doc.location; + + if (!existingHasLocation && docHasLocation) { + map.set(qid, doc); + } + } + + return [...map.values()]; +} + +function makeFixedCategoryMapper(category) { + return (row, layerId) => buildEventDoc(row, layerId, () => category); +} + +async function importCategory({ layer, category, buildQuery, dryRun }) { + console.log(`\n→ Importing category: ${category}`); + + const sparql = buildQuery(layer.rangeStart, layer.rangeEnd); const rows = await fetchFromWikidata(sparql); console.log(`Wikidata returned ${rows.length} rows`); - const docs = rows.map((row) => mapWarEvent(row, layer._id)).filter(Boolean); + const mapRow = makeFixedCategoryMapper(category); + const docsRaw = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docs = dedupeByWikidataQid(docsRaw); + + console.log(`Deduped ${docsRaw.length} → ${docs.length} by wikidataQid`); + console.log( `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, ); if (dryRun) { - console.log("Sample (first 3):", JSON.stringify(docs.slice(0, 3), null, 2)); + console.log("Sample (first 2):", JSON.stringify(docs.slice(0, 2), null, 2)); return { + category, total: rows.length, mapped: docs.length, upserted: 0, - dryRun: true, + modified: 0, + }; + } + + if (docs.length === 0) { + return { + category, + total: rows.length, + mapped: 0, + upserted: 0, + modified: 0, }; } @@ -84,19 +119,65 @@ export async function runWarImport({ dryRun = false } = {}) { const result = await Event.bulkWrite(ops, { ordered: false }); - const summary = { + return { + category, total: rows.length, mapped: docs.length, upserted: result.upsertedCount, modified: result.modifiedCount, - dryRun: false, }; +} + +export async function runWarImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ + slug: "war_organized_violence_europe", + }).lean(); + if (!layer) throw new Error("War layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + console.log( + `Range: ${layer.rangeStart.toISOString()} → ${layer.rangeEnd.toISOString()}`, + ); + + const tasks = [ + { category: "interstate_wars", buildQuery: buildInterstateWarsQuery }, + { category: "civil_wars", buildQuery: buildCivilWarsQuery }, + { + category: "revolutions_uprisings", + buildQuery: buildRevolutionsUprisingsQuery, + }, + { + category: "genocides_mass_violence", + buildQuery: buildGenocidesMassViolenceQuery, + }, + { category: "military_alliances", buildQuery: buildMilitaryAlliancesQuery }, + ]; + + const results = []; + for (const t of tasks) { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } + + const summary = results.reduce( + (acc, r) => { + acc.total += r.total; + acc.mapped += r.mapped; + acc.upserted += r.upserted; + acc.modified += r.modified; + return acc; + }, + { total: 0, mapped: 0, upserted: 0, modified: 0 }, + ); - console.log("Import complete:", summary); - return summary; + const full = { ...summary, perCategory: results, dryRun }; + console.log("\nImport complete:", full); + return full; } -// CLI entry point -// node backend/jobs/import/war.job.js -// node backend/jobs/import/war.job.js --dry-run startFromCLI("war.job.js", runWarImport, "war"); From 10c9d02959f9ad3d3de3fa7f49b2417d1d25540c Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Mon, 2 Mar 2026 14:16:15 +0100 Subject: [PATCH 18/36] fix(medicine): dedupe imported events by wikidataQid --- backend/jobs/import/medicine.job.js | 41 +++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js index 6e53f57d4..2fb709929 100644 --- a/backend/jobs/import/medicine.job.js +++ b/backend/jobs/import/medicine.job.js @@ -44,6 +44,34 @@ async function fetchFromWikidata(sparql) { return data.results.bindings; } +/** + * Dedupes mapped docs by Wikidata QID. + * Prefers a doc with a more specific location if duplicates exist. + */ +function dedupeByWikidataQid(docs) { + const map = new Map(); + + for (const doc of docs) { + const qid = doc?.externalIds?.wikidataQid; + if (!qid) continue; + + const existing = map.get(qid); + if (!existing) { + map.set(qid, doc); + continue; + } + + const existingHasLocation = !!existing.location; + const docHasLocation = !!doc.location; + + if (!existingHasLocation && docHasLocation) { + map.set(qid, doc); + } + } + + return [...map.values()]; +} + function makeFixedCategoryMapper(category) { return (row, layerId) => buildEventDoc(row, layerId, () => category); } @@ -59,8 +87,11 @@ async function importCategory({ layer, category, buildQuery, dryRun }) { console.log(`Wikidata returned ${rows.length} rows`); const mapRow = makeFixedCategoryMapper(category); - const docs = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docsRaw = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docs = dedupeByWikidataQid(docsRaw); + + console.log(`Deduped ${docsRaw.length} → ${docs.length} by wikidataQid`); console.log( `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, ); @@ -153,7 +184,6 @@ export async function runMedicineImport({ dryRun = false } = {}) { const results = []; for (const t of tasks) { - // sequential is kinder to Wikidata + easier to read logs const r = await importCategory({ layer, category: t.category, @@ -174,12 +204,7 @@ export async function runMedicineImport({ dryRun = false } = {}) { { total: 0, mapped: 0, upserted: 0, modified: 0 }, ); - const full = { - ...summary, - perCategory: results, - dryRun, - }; - + const full = { ...summary, perCategory: results, dryRun }; console.log("\nImport complete:", full); return full; } From 7bc1fd93464231b4236003426fe710384e7f0f79 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Mon, 2 Mar 2026 16:19:41 +0100 Subject: [PATCH 19/36] "chore(import): continue medicine import when a category fails" --- .../queries/medicine/hospitals.query.js | 7 -- .../queries/medicine/vaccines.query.js | 3 +- backend/jobs/import/medicine.job.js | 94 ++++++++++++++----- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/backend/integrations/wikidata/queries/medicine/hospitals.query.js b/backend/integrations/wikidata/queries/medicine/hospitals.query.js index 032a0a173..4db590472 100644 --- a/backend/integrations/wikidata/queries/medicine/hospitals.query.js +++ b/backend/integrations/wikidata/queries/medicine/hospitals.query.js @@ -23,17 +23,10 @@ ${baseSelect()} { ?event wdt:P571 ?startDate . # inception for institutions - } UNION { - ?event wdt:P580 ?startDate . - } UNION { - ?event wdt:P577 ?startDate . } - FILTER(?startDate >= "${from}"^^xsd:dateTime) FILTER(?startDate <= "${to}"^^xsd:dateTime) - OPTIONAL { ?event wdt:P582 ?endDate . } - FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } ${baseEuropeFilter()} diff --git a/backend/integrations/wikidata/queries/medicine/vaccines.query.js b/backend/integrations/wikidata/queries/medicine/vaccines.query.js index e63075925..4f77f5be2 100644 --- a/backend/integrations/wikidata/queries/medicine/vaccines.query.js +++ b/backend/integrations/wikidata/queries/medicine/vaccines.query.js @@ -16,8 +16,7 @@ ${basePrefixes()} ${baseSelect()} VALUES ?type { - wd:Q11461 # vaccine - wd:Q134808 # vaccination (sometimes used) + wd:Q134808 # vaccination } ?event wdt:P31 ?type . diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js index 2fb709929..e1743f51d 100644 --- a/backend/jobs/import/medicine.job.js +++ b/backend/jobs/import/medicine.job.js @@ -6,7 +6,7 @@ import buildEpidemicsQuery from "../../integrations/wikidata/queries/medicine/ep import buildVaccinesQuery from "../../integrations/wikidata/queries/medicine/vaccines.query.js"; import buildMedicalDiscoveriesQuery from "../../integrations/wikidata/queries/medicine/medicalDiscoveries.query.js"; import buildPublicHealthQuery from "../../integrations/wikidata/queries/medicine/publicHealth.query.js"; -import buildHospitalsQuery from "../../integrations/wikidata/queries/medicine/hospitals.query.js"; +//import buildHospitalsQuery from "../../integrations/wikidata/queries/medicine/hospitals.query.js"; import buildGermTheoryQuery from "../../integrations/wikidata/queries/medicine/germTheory.query.js"; import { buildEventDoc } from "../../integrations/wikidata/mappers/_mapperUtils.js"; @@ -22,26 +22,57 @@ const USER_AGENT = * @param {string} sparql * @returns {Promise} */ -async function fetchFromWikidata(sparql) { +async function fetchFromWikidata(sparql, { retries = 3 } = {}) { const url = new URL(WIKIDATA_ENDPOINT); url.searchParams.set("query", sparql); url.searchParams.set("format", "json"); - const response = await fetch(url.toString(), { - headers: { - Accept: "application/sparql-results+json", - "User-Agent": USER_AGENT, - }, - }); + let lastErr; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + const text = await response.text(); + + // Retry on transient upstream issues + if ( + [429, 502, 503, 504].includes(response.status) && + attempt < retries + ) { + const delayMs = 800 * attempt; + console.log( + `Wikidata ${response.status} (attempt ${attempt}/${retries}) – retrying in ${delayMs}ms...`, + ); + await new Promise((r) => setTimeout(r, delayMs)); + continue; + } + + throw new Error(`Wikidata responded with ${response.status}: ${text}`); + } - if (!response.ok) { - throw new Error( - `Wikidata responded with ${response.status}: ${await response.text()}`, - ); + const data = await response.json(); + return data.results.bindings; + } catch (err) { + lastErr = err; + if (attempt < retries) { + const delayMs = 800 * attempt; + console.log( + `Fetch failed (attempt ${attempt}/${retries}) – retrying in ${delayMs}ms...`, + ); + await new Promise((r) => setTimeout(r, delayMs)); + continue; + } + } } - const data = await response.json(); - return data.results.bindings; + throw lastErr; } /** @@ -173,8 +204,8 @@ export async function runMedicineImport({ dryRun = false } = {}) { buildQuery: buildPublicHealthQuery, }, { - category: "hospital_systems", - buildQuery: buildHospitalsQuery, + // category: "hospital_systems", + // buildQuery: buildHospitalsQuery, }, { category: "germ_theory_bacteriology", @@ -184,15 +215,28 @@ export async function runMedicineImport({ dryRun = false } = {}) { const results = []; for (const t of tasks) { - const r = await importCategory({ - layer, - category: t.category, - buildQuery: t.buildQuery, - dryRun, - }); - results.push(r); + try { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } catch (err) { + console.log( + `Skipping category ${t.category} due to error: ${err.message}`, + ); + results.push({ + category: t.category, + total: 0, + mapped: 0, + upserted: 0, + modified: 0, + error: err.message, + }); + } } - const summary = results.reduce( (acc, r) => { acc.total += r.total; @@ -213,3 +257,5 @@ export async function runMedicineImport({ dryRun = false } = {}) { // node backend/jobs/import/medicine.job.js // node backend/jobs/import/medicine.job.js --dry-run startFromCLI("medicine.job.js", runMedicineImport, "medicine"); + +// TODO: category: "hospital_systems", From f929e3ca334611c4f62b1172273a3d2e4d106fc9 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 4 Mar 2026 12:22:43 +0100 Subject: [PATCH 20/36] refactor(import): unify query structure and stabilize import jobs - move shared SPARQL helpers to queries/_shared.js - fix war.job per-category error handling - remove invalid medicine task placeholder - add dotenv loading for CLI import jobs - verify war + medicine imports with dry-run --- .../integrations/wikidata/queries/_shared.js | 69 ++++++++++++++ .../wikidata/queries/medicine/_shared.js | 84 +++-------------- .../wikidata/queries/war/_shared.js | 92 +++++-------------- backend/jobs/import/_runImport.js | 3 + backend/jobs/import/medicine.job.js | 5 - backend/jobs/import/war.job.js | 28 ++++-- frontend/src/layouts/AppLayout.jsx | 2 - 7 files changed, 129 insertions(+), 154 deletions(-) create mode 100644 backend/integrations/wikidata/queries/_shared.js diff --git a/backend/integrations/wikidata/queries/_shared.js b/backend/integrations/wikidata/queries/_shared.js new file mode 100644 index 000000000..81115c2e3 --- /dev/null +++ b/backend/integrations/wikidata/queries/_shared.js @@ -0,0 +1,69 @@ +export function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +export function basePrefixes() { + return ` +PREFIX xsd: +PREFIX schema: + `.trim(); +} + +export function baseSelect() { + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?type ?typeLabel + ?article +WHERE { + `.trim(); +} + +export function baseEuropeFilter() { + return ` + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + `.trim(); +} + +export function baseDates(from, to) { + return ` + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} + +export function baseWikipedia() { + return ` + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/_shared.js b/backend/integrations/wikidata/queries/medicine/_shared.js index 81115c2e3..d6496e84e 100644 --- a/backend/integrations/wikidata/queries/medicine/_shared.js +++ b/backend/integrations/wikidata/queries/medicine/_shared.js @@ -1,69 +1,15 @@ -export function formatDate(date) { - return date.toISOString().split("T")[0]; -} - -export function basePrefixes() { - return ` -PREFIX xsd: -PREFIX schema: - `.trim(); -} - -export function baseSelect() { - return ` -SELECT DISTINCT - ?event ?eventLabel ?eventDescription - ?startDate ?endDate - ?countryLabel ?locationLabel - ?type ?typeLabel - ?article -WHERE { - `.trim(); -} - -export function baseEuropeFilter() { - return ` - { - ?event wdt:P17 ?country . - ?country wdt:P30 wd:Q46 . - } UNION { - ?event wdt:P276 ?location . - ?location wdt:P30 wd:Q46 . - } UNION { - ?event wdt:P30 wd:Q46 . - } - - OPTIONAL { ?event wdt:P17 ?country . } - OPTIONAL { ?event wdt:P276 ?location . } - `.trim(); -} - -export function baseDates(from, to) { - return ` - { - ?event wdt:P580 ?startDate . - } UNION { - ?event wdt:P577 ?startDate . - } - - FILTER(?startDate >= "${from}"^^xsd:dateTime) - FILTER(?startDate <= "${to}"^^xsd:dateTime) - - OPTIONAL { ?event wdt:P582 ?endDate . } - `.trim(); -} - -export function baseWikipedia() { - return ` - OPTIONAL { - ?article schema:about ?event . - ?article schema:inLanguage "en" . - FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) - } - - SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } -} -ORDER BY ?startDate -LIMIT 2000 - `.trim(); -} +export { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseDates, + baseWikipedia, +} from "../_shared.js"; + +// Backwards-compatible aliases (om några medicine queries använder andra namn) +export { basePrefixes as prefixes } from "../_shared.js"; +export { baseSelect as selectBase } from "../_shared.js"; +export { baseEuropeFilter as europeFilter } from "../_shared.js"; +export { baseDates as dateFilterP580 } from "../_shared.js"; +export { baseWikipedia as wikipediaAndLabels } from "../_shared.js"; diff --git a/backend/integrations/wikidata/queries/war/_shared.js b/backend/integrations/wikidata/queries/war/_shared.js index 9bc0b6c02..268e5ade9 100644 --- a/backend/integrations/wikidata/queries/war/_shared.js +++ b/backend/integrations/wikidata/queries/war/_shared.js @@ -1,82 +1,32 @@ -export function formatDate(date) { - return date.toISOString().split("T")[0]; -} - -export function prefixes() { - return ` -PREFIX xsd: -PREFIX schema: - `.trim(); -} - -export function selectBase() { - return ` -SELECT DISTINCT - ?event ?eventLabel ?eventDescription - ?startDate ?endDate - ?countryLabel ?locationLabel - ?article -WHERE { - `.trim(); -} - -export function europeFilter() { - return ` - { - ?event wdt:P17 ?country . - ?country wdt:P30 wd:Q46 . - } UNION { - ?event wdt:P276 ?location . - ?location wdt:P30 wd:Q46 . - } UNION { - ?event wdt:P30 wd:Q46 . - } - - OPTIONAL { ?event wdt:P17 ?country . } - OPTIONAL { ?event wdt:P276 ?location . } - `.trim(); -} - -export function dateFilterP580(from, to) { - return ` - ?event wdt:P580 ?startDate . - - FILTER(?startDate >= "${from}"^^xsd:dateTime) - FILTER(?startDate <= "${to}"^^xsd:dateTime) - - OPTIONAL { ?event wdt:P582 ?endDate . } - `.trim(); -} - +export { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseDates, + baseWikipedia, +} from "../_shared.js"; + +// Backwards-compatible aliases (så gamla war queries fortsätter funka) +export { basePrefixes as prefixes } from "../_shared.js"; +export { baseSelect as selectBase } from "../_shared.js"; +export { baseEuropeFilter as europeFilter } from "../_shared.js"; +export { baseDates as dateFilterP580 } from "../_shared.js"; +export { baseWikipedia as wikipediaAndLabels } from "../_shared.js"; + +// War-specific helper (used by e.g. militaryAlliances.query.js) export function dateFilterAlliance(from, to) { - // Alliances/treaties often use inception (P571) or point-in-time (P585) instead of P580. return ` { - ?event wdt:P580 ?startDate . + ?event wdt:P571 ?startDate . # inception } UNION { - ?event wdt:P571 ?startDate . - } UNION { - ?event wdt:P585 ?startDate . + ?event wdt:P580 ?startDate . # start time (fallback) } FILTER(?startDate >= "${from}"^^xsd:dateTime) FILTER(?startDate <= "${to}"^^xsd:dateTime) - OPTIONAL { ?event wdt:P582 ?endDate . } - `.trim(); -} - -export function wikipediaAndLabels() { - return ` - OPTIONAL { - ?article schema:about ?event . - ?article schema:inLanguage "en" . - FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) - } - - SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } -} -ORDER BY ?startDate -LIMIT 2000 + OPTIONAL { ?event wdt:P576 ?endDate . } # dissolved/abolished + OPTIONAL { ?event wdt:P582 ?endDate . } # end time (fallback) `.trim(); } diff --git a/backend/jobs/import/_runImport.js b/backend/jobs/import/_runImport.js index 1d0d984cb..379c39087 100644 --- a/backend/jobs/import/_runImport.js +++ b/backend/jobs/import/_runImport.js @@ -1,5 +1,8 @@ +import dotenv from "dotenv"; import mongoose from "mongoose"; +dotenv.config({ path: new URL("../../.env", import.meta.url), quiet: true }); + export async function runImport({ importFn, jobName, diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js index e1743f51d..09e7dd27b 100644 --- a/backend/jobs/import/medicine.job.js +++ b/backend/jobs/import/medicine.job.js @@ -6,7 +6,6 @@ import buildEpidemicsQuery from "../../integrations/wikidata/queries/medicine/ep import buildVaccinesQuery from "../../integrations/wikidata/queries/medicine/vaccines.query.js"; import buildMedicalDiscoveriesQuery from "../../integrations/wikidata/queries/medicine/medicalDiscoveries.query.js"; import buildPublicHealthQuery from "../../integrations/wikidata/queries/medicine/publicHealth.query.js"; -//import buildHospitalsQuery from "../../integrations/wikidata/queries/medicine/hospitals.query.js"; import buildGermTheoryQuery from "../../integrations/wikidata/queries/medicine/germTheory.query.js"; import { buildEventDoc } from "../../integrations/wikidata/mappers/_mapperUtils.js"; @@ -203,10 +202,6 @@ export async function runMedicineImport({ dryRun = false } = {}) { category: "public_health_reforms", buildQuery: buildPublicHealthQuery, }, - { - // category: "hospital_systems", - // buildQuery: buildHospitalsQuery, - }, { category: "germ_theory_bacteriology", buildQuery: buildGermTheoryQuery, diff --git a/backend/jobs/import/war.job.js b/backend/jobs/import/war.job.js index d02596c55..ab2569182 100644 --- a/backend/jobs/import/war.job.js +++ b/backend/jobs/import/war.job.js @@ -155,13 +155,27 @@ export async function runWarImport({ dryRun = false } = {}) { const results = []; for (const t of tasks) { - const r = await importCategory({ - layer, - category: t.category, - buildQuery: t.buildQuery, - dryRun, - }); - results.push(r); + try { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } catch (err) { + console.log( + `Skipping category ${t.category} due to error: ${err.message}`, + ); + results.push({ + category: t.category, + total: 0, + mapped: 0, + upserted: 0, + modified: 0, + error: err.message, + }); + } } const summary = results.reduce( diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx index 775d8e096..64da7c66d 100644 --- a/frontend/src/layouts/AppLayout.jsx +++ b/frontend/src/layouts/AppLayout.jsx @@ -1,5 +1,3 @@ -// frontend/src/layouts/AppLayout.jsx - import { Outlet, Link } from "react-router-dom"; import { useAuthStore } from "../stores/authStore"; import styles from "./AppLayout.module.css"; From e5c80778821ffe7dc2f87eb51ba282cdf7d222d9 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 4 Mar 2026 14:58:19 +0100 Subject: [PATCH 21/36] feat: add responsive sidebar navigation and improve timeline positioning --- frontend/src/api/queryKeys.js | 11 +- frontend/src/features/layers/hooks.js | 1 + .../src/features/timeline/TimelinePage.jsx | 62 ++++++++-- frontend/src/features/timeline/constants.js | 17 ++- .../timeline/styles/TimelinePage.module.css | 39 +++++++ frontend/src/index.css | 43 +++++++ frontend/src/layouts/AppLayout.jsx | 80 +++++++++++-- frontend/src/layouts/AppLayout.module.css | 108 +++++++++++++++++- 8 files changed, 336 insertions(+), 25 deletions(-) diff --git a/frontend/src/api/queryKeys.js b/frontend/src/api/queryKeys.js index 51f3b9faf..e4d1e6704 100644 --- a/frontend/src/api/queryKeys.js +++ b/frontend/src/api/queryKeys.js @@ -1,5 +1,14 @@ export const queryKeys = { layers: ["layers"], - layerEvents: (layerId, params) => ["layers", layerId, "events", params], + + layerEvents: (layerId, params = {}) => [ + "layers", + layerId, + "events", + params.from ?? null, + params.to ?? null, + params.category ?? null, + ], + savedComparisons: ["savedComparisons"], }; diff --git a/frontend/src/features/layers/hooks.js b/frontend/src/features/layers/hooks.js index f7761a245..49c073761 100644 --- a/frontend/src/features/layers/hooks.js +++ b/frontend/src/features/layers/hooks.js @@ -14,5 +14,6 @@ export function useLayerEvents(layerId, params = {}) { queryKey: queryKeys.layerEvents(layerId, params), queryFn: () => fetchLayerEvents(layerId, params), enabled: !!layerId, + keepPreviousData: true, }); } diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx index 92c778c1e..96632ad3f 100644 --- a/frontend/src/features/timeline/TimelinePage.jsx +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -6,24 +6,61 @@ import TimelineRow from "./components/TimelineRow"; import YearAxis from "./components/YearAxis"; import EventPanel from "./components/EventPanel"; import styles from "./styles/TimelinePage.module.css"; - -function ActiveLayer({ layerId, layers, selectedEvent, onEventClick }) { +function ActiveLayer({ + layerId, + layers, + selectedEvent, + onEventClick, + category, + onCategoryChange, +}) { const layer = layers.find((l) => l._id === layerId); - const { data, isLoading, isError } = useLayerEvents(layerId); + + const { data, isLoading, isError } = useLayerEvents(layerId, { + category: category || undefined, + }); if (!layer) return null; + if (isLoading) return

Loading {layer.name}...

; + if (isError) return

Failed to load {layer.name}

; return ( - + <> + {/* CATEGORY FILTER */} +
+ + + + {data?.events?.length ?? 0} events + +
+ {/* TIMELINE */} + + ); } @@ -31,6 +68,7 @@ export default function TimelinePage() { const { data: layersData, isLoading, isError } = useLayers(); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [selectedEvent, setSelectedEvent] = useState(null); + const [categoryByLayerId, setCategoryByLayerId] = useState({}); const layers = layersData ?? []; @@ -52,6 +90,10 @@ export default function TimelinePage() { setSelectedEvent((prev) => (prev?._id === event._id ? null : event)); }, []); + const handleCategoryChange = useCallback((layerId, value) => { + setCategoryByLayerId((prev) => ({ ...prev, [layerId]: value || null })); + }, []); + if (isLoading) return

Loading layers...

; if (isError) return

Failed to load layers.

; @@ -80,6 +122,8 @@ export default function TimelinePage() { layers={layers} selectedEvent={selectedEvent} onEventClick={handleEventClick} + category={categoryByLayerId[id] ?? ""} + onCategoryChange={handleCategoryChange} /> ))} diff --git a/frontend/src/features/timeline/constants.js b/frontend/src/features/timeline/constants.js index d8fca3c87..56a3d19a5 100644 --- a/frontend/src/features/timeline/constants.js +++ b/frontend/src/features/timeline/constants.js @@ -20,12 +20,23 @@ export const LAYER_ACCENT = { }; export const RANGE_START = new Date("1500-01-01").getTime(); -export const RANGE_END = new Date("2000-01-01").getTime(); +export const RANGE_END = new Date("2001-01-01").getTime(); export const RANGE_SPAN = RANGE_END - RANGE_START; -export function dateToPercent(date) { +export function dateToPercent( + date, + rangeStart = RANGE_START, + rangeEnd = RANGE_END, +) { const t = new Date(date).getTime(); - return ((t - RANGE_START) / RANGE_SPAN) * 100; + const start = rangeStart instanceof Date ? rangeStart.getTime() : rangeStart; + const end = rangeEnd instanceof Date ? rangeEnd.getTime() : rangeEnd; + + const span = end - start; + if (!Number.isFinite(t) || span <= 0) return 0; + + const p = ((t - start) / span) * 100; + return Math.max(0, Math.min(100, p)); } export function formatYear(date) { diff --git a/frontend/src/features/timeline/styles/TimelinePage.module.css b/frontend/src/features/timeline/styles/TimelinePage.module.css index 27a21d1e6..9faf0e83e 100644 --- a/frontend/src/features/timeline/styles/TimelinePage.module.css +++ b/frontend/src/features/timeline/styles/TimelinePage.module.css @@ -29,6 +29,37 @@ position: relative; } +.layerControls { + display: flex; + align-items: center; + gap: 1rem; + margin: 0.75rem 0; + flex-wrap: wrap; +} + +.select { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.4rem 0.6rem; + border-radius: 6px; +} + +.controlLabel { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: #b8b8b8; +} + +.contInline { + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: #b8b8b8; +} + .status { color: #333; font-family: "JetBrains Mono", monospace; @@ -40,3 +71,11 @@ font-family: "JetBrains Mono", monospace; font-size: 0.8rem; } + +@media (max-width: 600px) { + .layerControls { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb..e131e957c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,43 @@ +/* Reset + bättre layout-beteende */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Grundlayout */ +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #05060a; + color: #e8e4d9; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* Länkar */ +a { + color: inherit; + text-decoration: none; +} + +/* Accessibility focus */ +a:focus, +button:focus, +select:focus, +input:focus { + outline: 2px solid #7aa2f7; + outline-offset: 2px; +} + +/* Bilder */ +img { + max-width: 100%; + display: block; +} diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx index 64da7c66d..4d1147491 100644 --- a/frontend/src/layouts/AppLayout.jsx +++ b/frontend/src/layouts/AppLayout.jsx @@ -1,17 +1,40 @@ import { Outlet, Link } from "react-router-dom"; +import { useEffect, useState } from "react"; import { useAuthStore } from "../stores/authStore"; import styles from "./AppLayout.module.css"; export default function AppLayout() { const { user, logout, isAuthenticated } = useAuthStore(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 900); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 900); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const closeSidebar = () => setIsSidebarOpen(false); return (
- -
- -
+ + + {/* Overlay (mobile) */} + {isMobile && isSidebarOpen && ( +
); } diff --git a/frontend/src/layouts/AppLayout.module.css b/frontend/src/layouts/AppLayout.module.css index 7c2366231..c7a43a492 100644 --- a/frontend/src/layouts/AppLayout.module.css +++ b/frontend/src/layouts/AppLayout.module.css @@ -7,10 +7,12 @@ font-family: "JetBrains Mono", monospace; } -.nav { +/* TOPBAR */ +.topbar { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 1rem; padding: 1.25rem 2.5rem; border-bottom: 1px solid #1a1a2e; } @@ -25,7 +27,7 @@ text-transform: uppercase; } -.navLinks { +.topbarRight { display: flex; gap: 1.5rem; align-items: center; @@ -66,6 +68,106 @@ color: #e8e4d9; } +/* LAYOUT: sidebar + main */ +.shell { + display: grid; + grid-template-columns: 260px 1fr; + min-height: calc(100vh - 72px); /* approx topbar height */ +} + +/* SIDEBAR (desktop visible) */ +.sidebar { + border-right: 1px solid #1a1a2e; + padding: 1.25rem; +} + +.sideNav { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sideLink { + color: #b8b8b8; + text-decoration: none; + font-size: 0.85rem; + letter-spacing: 0.05em; +} + +.sideLink:hover { + color: #e8e4d9; +} + +/* MAIN */ .main { width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 1rem; +} + +/* BURGER (hidden on desktop) */ +.burger { + display: none; + background: none; + border: 1px solid #1a1a2e; + color: #e8e4d9; + border-radius: 8px; + padding: 0.35rem 0.6rem; + cursor: pointer; + line-height: 1; +} + +/* OVERLAY (mobile only) */ +.overlay { + display: none; +} + +/* MOBILE: sidebar becomes drawer */ +@media (max-width: 900px) { + .topbar { + padding: 1rem; + } + + .topbarRight { + gap: 1rem; + } + + .shell { + grid-template-columns: 1fr; + min-height: calc(100vh - 64px); + } + + .burger { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + background: #0a0a0f; + transform: translateX(-110%); + transition: transform 0.2s ease; + z-index: 50; + border-right: 1px solid #1a1a2e; + padding: 1.25rem; + } + + .sidebarOpen { + transform: translateX(0); + } + + .overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + border: none; + z-index: 40; + } } From 9662a2999d1b2554a66570069bb229d6e50779f0 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 4 Mar 2026 16:22:27 +0100 Subject: [PATCH 22/36] Improve timeline responsiveness, accessibility, and add project README --- README.md | 355 ++++++++++++++++++ backend/README.md | 10 +- frontend/README.md | 10 +- frontend/src/api/queryKeys.js | 3 +- .../src/features/timeline/TimelinePage.jsx | 64 ++-- .../features/timeline/components/EventDot.jsx | 8 +- .../timeline/components/TimelineRow.jsx | 25 +- .../timeline/styles/EventDot.module.css | 23 +- .../timeline/styles/TimelinePage.module.css | 105 ++---- .../timeline/styles/TimelineRow.module.css | 4 + 10 files changed, 481 insertions(+), 126 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..9fb2d274e --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +# Chronos — Visual History Timeline + +A full-stack interactive history analysis tool that allows users to explore and compare historical events across multiple timelines. + +Tech stack: +React • Node.js • Express • MongoDB • React Query • Zustand + +# Chronos — Visual History Timeline + +Chronos is a visual history analysis tool that allows users to explore and compare historical events across multiple domains using interactive timelines. + +The application makes it possible to place different historical processes on parallel timelines (for example war, medicine, or political change) in order to explore patterns, overlaps, and historical context. + +The project was built as a full-stack application using React, Node.js, Express, and MongoDB. + +--- + +# Features + +- Interactive timeline visualization +- Multiple timeline layers that can be compared +- Category filtering for each layer +- Event clustering when events occur close in time +- Event detail panel +- User authentication (signup/login) +- Data imported automatically from Wikidata +- Responsive design (mobile → desktop) +- Accessible interface with keyboard navigation + +Example comparison: + +War timeline vs Medicine timeline + +This makes it easier to explore relationships between historical developments across domains. + +--- + +# Tech Stack + +## Frontend + +- React +- React Router +- React Query +- Zustand (global state) +- Vite + +## Backend + +- Node.js +- Express + +## Database + +- MongoDB +- Mongoose + +## External Data + +- Wikidata SPARQL API + +--- + +# Architecture Overview + +The project follows a **feature-based architecture**. + +Instead of organizing files only by type, code is grouped by functionality. + +Example: + +frontend/src/features/ + +layers → fetching layer data +timeline → timeline UI and visualization + +This structure improves scalability and maintainability. + +--- + +# Project Structure + +## Backend + +backend/ + +api/layers +integrations/wikidata +jobs/import +models +routes +middleware + +Important files: + +server.js → Express server +Event.js → timeline event model +Layer.js → timeline layer model +User.js → authentication model + +--- + +## Frontend + +frontend/src/ + +api/ → HTTP client and query keys +app/ → router + React Query client +features/ → feature-based modules +components/ → shared components +layouts/ → layout system +pages/ → main application pages +stores/ → Zustand global state + +Example feature module: + +features/timeline/ + +TimelinePage.jsx +TimelineRow.jsx +EventDot.jsx +YearAxis.jsx +EventPanel.jsx + +--- + +# Data Model + +## Layer + +Represents a domain timeline. + +Example: + +War & Organized Violence +Medicine & Disease + +Fields: + +name +slug +categories +rangeStart +rangeEnd + +--- + +## Event + +Represents a historical event. + +Fields: + +layerId +title +summary +startDate +endDate +category +tags +location +sources +externalIds.wikidataQid +lastSyncedAt + +--- + +# API Endpoints + +GET /api/layers + +Returns available timeline layers. + +GET /api/layers/:id/events + +Returns events for a specific layer. + +Optional query parameters: + +category +from +to + +Example: + +/api/layers/war/events?category=civil_war + +--- + +# Wikidata Import System + +Historical events are imported from Wikidata using SPARQL queries. + +Each domain has its own import job. + +Example jobs: + +war.job.js +medicine.job.js + +Import pipeline: + +SPARQL query +→ map results +→ deduplicate by Wikidata QID +→ upsert into MongoDB + +Example command: + +node jobs/import/war.job.js --dry-run + +Dry run allows testing the import without writing to the database. + +--- + +# Timeline System + +Events are rendered as dots on a horizontal timeline. + +The position is calculated using a helper function: + +dateToPercent() + +This converts event dates into positions on the timeline. + +Events close in time are automatically grouped into clusters. + +Clicking a cluster expands it to reveal individual events. + +--- + +# Authentication + +User authentication is implemented with: + +- email + password +- protected routes +- JWT authentication +- Zustand auth state + +Authenticated users can access the main application area. + +--- + +# Responsive Design + +The application is designed mobile-first and works on screens between: + +320px → 1600px + +Mobile improvements include: + +- wrapped layer selector +- touch-friendly event dots +- flexible timeline layout + +--- + +# Accessibility + +Accessibility improvements include: + +- semantic HTML elements +- keyboard-accessible timeline events +- focus states +- ARIA labels +- reduced motion support + +The goal is to achieve a Lighthouse accessibility score of 100. + +--- + +# Running the Project Locally + +## 1. Clone repository + +git clone https://github.com/your-username/chronos + +--- + +## 2. Backend setup + +cd backend + +npm install + +Create a .env file: + +MONGO_URI=your-mongodb-uri +JWT_SECRET=your-secret + +Run server: + +npm run dev + +--- + +## 3. Frontend setup + +cd frontend + +npm install + +Run development server: + +npm run dev + +The frontend will typically run on: + +http://localhost:5173 + +--- + +# Demo Walkthrough + +1. Register a user account +2. Log in to the application +3. Select one or two timeline layers +4. Filter by category +5. Click events to open the detail panel +6. Expand clusters to inspect individual events +7. Compare historical patterns across domains + +--- + +# Future Improvements + +Possible future improvements include: + +- timeline zoom and custom year ranges +- smoother timeline animations +- improved clustering algorithm +- additional timeline layers +- saved timeline comparisons +- user annotations + +--- + +# Learning Goals + +This project was built to explore: + +- full-stack application architecture +- data visualization with React +- working with external data APIs +- designing scalable frontend structure +- building accessible interfaces + +--- + +# Author + +Sara Enderborg + +Chronos combines historical analysis with interactive data visualization to explore patterns across time. diff --git a/backend/README.md b/backend/README.md index d1438c910..b3941d68a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,8 @@ -# Backend part of Final Project +# Backend -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +Node.js + Express API for the Chronos timeline application. -## Getting Started +Run locally: -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +npm install +npm run dev diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..e6e7b932f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,8 @@ -# Frontend part of Final Project +# Frontend -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. +React + Vite application for the Chronos timeline interface. -## Getting Started +Run locally: -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +npm install +npm run dev diff --git a/frontend/src/api/queryKeys.js b/frontend/src/api/queryKeys.js index e4d1e6704..778b73218 100644 --- a/frontend/src/api/queryKeys.js +++ b/frontend/src/api/queryKeys.js @@ -1,6 +1,5 @@ export const queryKeys = { layers: ["layers"], - layerEvents: (layerId, params = {}) => [ "layers", layerId, @@ -8,7 +7,7 @@ export const queryKeys = { params.from ?? null, params.to ?? null, params.category ?? null, + params.tag ?? null, ], - savedComparisons: ["savedComparisons"], }; diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx index 96632ad3f..298a4414f 100644 --- a/frontend/src/features/timeline/TimelinePage.jsx +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -6,6 +6,7 @@ import TimelineRow from "./components/TimelineRow"; import YearAxis from "./components/YearAxis"; import EventPanel from "./components/EventPanel"; import styles from "./styles/TimelinePage.module.css"; + function ActiveLayer({ layerId, layers, @@ -28,31 +29,34 @@ function ActiveLayer({ if (isError) return

Failed to load {layer.name}

; + const selectId = `category-select-${layerId}`; + return ( <> - {/* CATEGORY FILTER */}
-
+ {/* TIMELINE */} 0 && selectedLayerIds.length === 0) { setSelectedLayerIds([layers[0]._id]); } - }, [layers]); + }, [layers, selectedLayerIds.length]); const handleToggleLayer = useCallback((id) => { setSelectedLayerIds((prev) => { - if (prev.includes(id)) return prev.filter((x) => x !== id); + if (prev.includes(id)) { + setCategoryByLayerId((cats) => { + const next = { ...cats }; + delete next[id]; + return next; + }); + return prev.filter((x) => x !== id); + } + if (prev.length >= 2) return [...prev.slice(1), id]; return [...prev, id]; }); @@ -99,22 +111,25 @@ export default function TimelinePage() { return

Failed to load layers.

; return ( -
+

Historical Timeline

Europe · 1500–2000

- +
+ +
{selectedLayerIds.length === 0 && (

Select a layer above to begin.

)} + {selectedLayerIds.map((id) => ( ))} +
diff --git a/frontend/src/features/timeline/components/EventDot.jsx b/frontend/src/features/timeline/components/EventDot.jsx index 0c3cf978e..97fd146b4 100644 --- a/frontend/src/features/timeline/components/EventDot.jsx +++ b/frontend/src/features/timeline/components/EventDot.jsx @@ -15,13 +15,17 @@ export default function EventDot({ const color = colorOverride ?? CATEGORY_COLORS[event.category] ?? "#888"; const left = leftOverride ?? dateToPercent(event.startDate); + const label = title ?? event.title; + return ( -
onClick(event)} - title={title ?? event.title} + title={label} + aria-label={label} data-count={dataCount} style={{ "--color": color, diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index 89606e69b..bb678996f 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -7,7 +7,7 @@ import EventDot from "./EventDot"; const CLUSTER_PX = 8; // lite tightare än 10 för att undvika mega-clusters const EXPLODE_DY = 12; // vertikalt varannan upp/ner const EXPLODE_DX = 10; // bara om flera har exakt samma x -const MAX_EXPLODE = 60; // visa fler i explode (valfritt) +const MAX_EXPLODE = 60; // visa fler i explode function getClusterColor(events) { const cats = new Set(events.map((x) => x.event.category)); @@ -15,7 +15,7 @@ function getClusterColor(events) { const cat = [...cats][0]; return CATEGORY_COLORS[cat] ?? "#888"; } - return "#b0b0b0"; // mixed + return "#b0b0b0"; } export default function TimelineRow({ @@ -30,13 +30,19 @@ export default function TimelineRow({ const [trackWidth, setTrackWidth] = useState(0); const [explodedKey, setExplodedKey] = useState(null); - // Measure track width useEffect(() => { if (!trackRef.current) return; const el = trackRef.current; + + const measure = () => { + const w = el.getBoundingClientRect().width; + setTrackWidth(Math.max(0, w - 20)); + }; + const ro = new ResizeObserver(() => { - setTrackWidth(el.getBoundingClientRect().width); + const w = el.getBoundingClientRect().width; + setTrackWidth(Math.max(0, w - 20)); }); ro.observe(el); @@ -45,7 +51,6 @@ export default function TimelineRow({ return () => ro.disconnect(); }, []); - // Build pixel-based clusters WITHOUT chain-merging const clusters = useMemo(() => { if (!events || events.length === 0) return []; @@ -74,7 +79,6 @@ export default function TimelineRow({ for (let i = 1; i < items.length; i++) { const curr = items[i]; - // IMPORTANT: compare to groupStartX to avoid chain effect if (curr.xPx - groupStartX <= CLUSTER_PX) { group.push(curr); } else { @@ -95,12 +99,11 @@ export default function TimelineRow({ key, type: g.length === 1 ? "single" : "cluster", leftPercent, - items: g, // [{event,leftPercent,xPx}] + items: g, }; }); }, [events, trackWidth, layer._id]); - // Close explosion if cluster disappears useEffect(() => { if (!explodedKey) return; const stillExists = clusters.some( @@ -146,7 +149,7 @@ export default function TimelineRow({ event={it.event} onClick={onEventClick} isSelected={selectedEvent?._id === it.event._id} - leftOverride={it.leftPercent} // explicit, stabilt + leftOverride={it.leftPercent} /> ); } @@ -186,12 +189,8 @@ export default function TimelineRow({ leftOverride={c.leftPercent} className={dotStyles.cluster} /> - - {/* exploded dots: show on their REAL year */} {isExploded && explodedItems.map((it, i) => { - // If multiple events share the same xPx (same pixel), nudge sideways. - // Group identical xPx into small stacks: const sameX = explodedItems.filter( (x) => Math.abs(x.xPx - it.xPx) < 0.5, ); diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css index 2f265a42a..93378e5e2 100644 --- a/frontend/src/features/timeline/styles/EventDot.module.css +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -1,19 +1,29 @@ .dot { + appearance: none; + -webkit-appearance: none; + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 0; + margin: 0; + background: var(--color); + font: inherit; + line-height: 1; + cursor: pointer; + position: absolute; left: var(--left); top: 50%; transform: translate(-50%, -50%) translate(var(--dx, 0px), var(--dy, 0px)); + width: 10px; height: 10px; border-radius: 50%; - background: var(--color); - border: 1px solid rgba(255, 255, 255, 0.15); - cursor: pointer; + transition: width 0.15s ease, height 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + z-index: 1; } @@ -22,6 +32,11 @@ height: 13px; } +.dot:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 3px; +} + .selected { width: 14px; height: 14px; @@ -30,7 +45,6 @@ z-index: 10; } -/* cluster-dot style */ .cluster { width: 14px; height: 14px; @@ -39,7 +53,6 @@ z-index: 5; } -/* count badge on cluster */ .cluster::after { content: attr(data-count); position: absolute; diff --git a/frontend/src/features/timeline/styles/TimelinePage.module.css b/frontend/src/features/timeline/styles/TimelinePage.module.css index 9faf0e83e..057eaa12a 100644 --- a/frontend/src/features/timeline/styles/TimelinePage.module.css +++ b/frontend/src/features/timeline/styles/TimelinePage.module.css @@ -1,81 +1,46 @@ -.page { - padding: 3rem 2.5rem; - min-height: 100vh; - background: #0a0a0f; +.wrapper { + margin-bottom: 2rem; + min-width: 0; } -.header { - margin-bottom: 3rem; -} - -.title { - font-family: "Cormorant Garamond", serif; - font-size: clamp(1.5rem, 4vw, 2.5rem); - font-weight: 300; - letter-spacing: 0.05em; - margin: 0 0 0.4rem; - color: #e8e4d9; -} - -.subtitle { - color: #333; - font-size: 0.8rem; +.label { + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 0.5rem; font-family: "JetBrains Mono", monospace; - margin: 0; - letter-spacing: 0.1em; -} - -.timeline { - position: relative; -} - -.layerControls { display: flex; align-items: center; - gap: 1rem; - margin: 0.75rem 0; + gap: 0.75rem; + min-width: 0; flex-wrap: wrap; } -.select { - background: #0a0a0f; - border: 1px solid #1a1a2e; - color: #e8e4d9; - padding: 0.4rem 0.6rem; - border-radius: 6px; -} - -.controlLabel { - display: flex; - align-items: center; - gap: 0.5rem; - font-family: "JetBrains Mono", monospace; - font-size: 0.75rem; - color: #b8b8b8; -} - -.contInline { - font-family: "JetBrains Mono", monospace; - font-size: 0.75rem; - color: #b8b8b8; +.count { + color: rgba(232, 228, 217, 0.7); } -.status { - color: #333; - font-family: "JetBrains Mono", monospace; - font-size: 0.8rem; -} - -.statusError { - color: #c0392b; - font-family: "JetBrains Mono", monospace; - font-size: 0.8rem; -} - -@media (max-width: 600px) { - .layerControls { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } +.track { + position: relative; + height: 48px; + background: #0f0f1a; + border: 1px solid #1a1a2e; + border-radius: 2px; + + width: 100%; + min-width: 0; + overflow: hidden; + padding: 0 10px; + box-sizing: border-box; +} + +.centerLine { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: #1a1a2e; + pointer-events: none; } diff --git a/frontend/src/features/timeline/styles/TimelineRow.module.css b/frontend/src/features/timeline/styles/TimelineRow.module.css index 5f2e9cf8d..9d6a8c379 100644 --- a/frontend/src/features/timeline/styles/TimelineRow.module.css +++ b/frontend/src/features/timeline/styles/TimelineRow.module.css @@ -24,6 +24,10 @@ background: #0f0f1a; border: 1px solid #1a1a2e; border-radius: 2px; + width: 100%; + min-width: 0; + overflow: hidden; + box-sizing: border-box; } .centerLine { From a05264ae782be62308cf9cb0da54871d73bf6623 Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Wed, 4 Mar 2026 21:55:12 +0100 Subject: [PATCH 23/36] feat: refactor timeline controls and improve timeline layout --- .../src/features/timeline/TimelinePage.jsx | 85 +++--------- .../timeline/components/TimelineControls.jsx | 67 ++++++++++ .../timeline/components/TimelineRow.jsx | 123 ++++++++++++------ .../features/timeline/components/YearAxis.jsx | 47 ++++--- .../features/timeline/components/ZoomBar.jsx | 87 +++++++++++++ .../timeline/styles/EventDot.module.css | 19 ++- .../styles/TimelineControls.module.css | 58 +++++++++ .../timeline/styles/TimelineRow.module.css | 54 ++++++-- .../timeline/styles/YearAxis.module.css | 21 ++- .../timeline/styles/ZoomBar.module.css | 61 +++++++++ frontend/src/layouts/AppLayout.jsx | 35 ++++- frontend/src/layouts/AppLayout.module.css | 18 ++- frontend/src/stores/uiStore.js | 45 +++++++ 13 files changed, 569 insertions(+), 151 deletions(-) create mode 100644 frontend/src/features/timeline/components/TimelineControls.jsx create mode 100644 frontend/src/features/timeline/components/ZoomBar.jsx create mode 100644 frontend/src/features/timeline/styles/TimelineControls.module.css create mode 100644 frontend/src/features/timeline/styles/ZoomBar.module.css diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx index 298a4414f..70bd52780 100644 --- a/frontend/src/features/timeline/TimelinePage.jsx +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -1,11 +1,11 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useLayers } from "../layers/hooks"; import { useLayerEvents } from "../layers/hooks"; -import LayerSelector from "./components/LayerSelector"; import TimelineRow from "./components/TimelineRow"; import YearAxis from "./components/YearAxis"; import EventPanel from "./components/EventPanel"; import styles from "./styles/TimelinePage.module.css"; +import { useUiStore } from "../../stores/uiStore"; function ActiveLayer({ layerId, @@ -13,7 +13,6 @@ function ActiveLayer({ selectedEvent, onEventClick, category, - onCategoryChange, }) { const layer = layers.find((l) => l._id === layerId); @@ -29,35 +28,8 @@ function ActiveLayer({ if (isError) return

Failed to load {layer.name}

; - const selectId = `category-select-${layerId}`; - return ( <> -
- - - - - - {data?.events?.length ?? 0} events - -
- - {/* TIMELINE */} s.selectedLayerIds); + const categoryByLayerId = useUiStore((s) => s.categoryByLayerId); + const [startYear, endYear] = useUiStore((s) => s.yearRange); + + const [selectedEvent, setSelectedEvent] = useState(null); + + // Default-select first layer once layers have loaded useEffect(() => { if (layers.length > 0 && selectedLayerIds.length === 0) { - setSelectedLayerIds([layers[0]._id]); + // Use store setter logic (toggle first layer) + // avoid importing toggleLayer here by using setState directly: + // simplest: just set it in store via setState + useUiStore.setState({ selectedLayerIds: [layers[0]._id] }); } }, [layers, selectedLayerIds.length]); - const handleToggleLayer = useCallback((id) => { - setSelectedLayerIds((prev) => { - if (prev.includes(id)) { - setCategoryByLayerId((cats) => { - const next = { ...cats }; - delete next[id]; - return next; - }); - return prev.filter((x) => x !== id); - } - - if (prev.length >= 2) return [...prev.slice(1), id]; - return [...prev, id]; - }); - }, []); - const handleEventClick = useCallback((event) => { setSelectedEvent((prev) => (prev?._id === event._id ? null : event)); }, []); - const handleCategoryChange = useCallback((layerId, value) => { - setCategoryByLayerId((prev) => ({ ...prev, [layerId]: value || null })); - }, []); - if (isLoading) return

Loading layers...

; if (isError) return

Failed to load layers.

; @@ -114,20 +72,16 @@ export default function TimelinePage() {

Historical Timeline

-

Europe · 1500–2000

-
- -
- +

+ Europe · {startYear}–{endYear} +

{selectedLayerIds.length === 0 && ( -

Select a layer above to begin.

+

+ Select a layer in the sidebar to begin. +

)} {selectedLayerIds.map((id) => ( @@ -138,7 +92,6 @@ export default function TimelinePage() { selectedEvent={selectedEvent} onEventClick={handleEventClick} category={categoryByLayerId[id] ?? ""} - onCategoryChange={handleCategoryChange} /> ))} diff --git a/frontend/src/features/timeline/components/TimelineControls.jsx b/frontend/src/features/timeline/components/TimelineControls.jsx new file mode 100644 index 000000000..3b91cbad8 --- /dev/null +++ b/frontend/src/features/timeline/components/TimelineControls.jsx @@ -0,0 +1,67 @@ +import { useUiStore } from "../../../stores/uiStore"; +import LayerSelector from "./LayerSelector"; +import ZoomBar from "./ZoomBar"; +import styles from "../styles/TimelineControls.module.css"; + +export default function TimelineControls({ layers }) { + const selectedLayerIds = useUiStore((s) => s.selectedLayerIds); + const toggleLayer = useUiStore((s) => s.toggleLayer); + + const categoryByLayerId = useUiStore((s) => s.categoryByLayerId); + const setCategoryForLayer = useUiStore((s) => s.setCategoryForLayer); + + const selectedLayers = selectedLayerIds + .map((id) => layers.find((l) => l._id === id)) + .filter(Boolean); + + return ( +
+
+

Layers

+ +
+ +
+

Year range

+ +
+ +
+

Categories

+ + {selectedLayers.length === 0 ? ( +

Select a layer to see category filters.

+ ) : ( +
+ {selectedLayers.map((layer) => { + const value = categoryByLayerId[layer._id] ?? ""; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index bb678996f..55f366cff 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -3,14 +3,17 @@ import dotStyles from "../styles/EventDot.module.css"; import styles from "../styles/TimelineRow.module.css"; import { LAYER_ACCENT, CATEGORY_COLORS, dateToPercent } from "../constants"; import EventDot from "./EventDot"; +import { useUiStore } from "../../../stores/uiStore"; -const CLUSTER_PX = 8; // lite tightare än 10 för att undvika mega-clusters -const EXPLODE_DY = 12; // vertikalt varannan upp/ner -const EXPLODE_DX = 10; // bara om flera har exakt samma x -const MAX_EXPLODE = 60; // visa fler i explode +const CLUSTER_PX = 8; +const EXPLODE_DY = 12; +const EXPLODE_DX = 10; +const MAX_EXPLODE = 60; +const CLUSTER_SEPARATION_PX = 18; +const STAGGER_DY = 10; -function getClusterColor(events) { - const cats = new Set(events.map((x) => x.event.category)); +function getClusterColor(items) { + const cats = new Set(items.map((x) => x.event.category)); if (cats.size === 1) { const cat = [...cats][0]; return CATEGORY_COLORS[cat] ?? "#888"; @@ -24,6 +27,25 @@ export default function TimelineRow({ selectedEvent, onEventClick, }) { + const [rangeStartYear, rangeEndYear] = useUiStore((s) => s.yearRange); + + const rangeStart = useMemo( + () => new Date(`${rangeStartYear}-01-01`), + [rangeStartYear], + ); + const rangeEnd = useMemo( + () => new Date(`${rangeEndYear}-12-31`), + [rangeEndYear], + ); + + const visibleEvents = useMemo(() => { + if (!events?.length) return []; + return events.filter((e) => { + const d = new Date(e.startDate); + return d >= rangeStart && d <= rangeEnd; + }); + }, [events, rangeStart, rangeEnd]); + const accent = LAYER_ACCENT[layer.slug] ?? "#888"; const trackRef = useRef(null); @@ -40,33 +62,31 @@ export default function TimelineRow({ setTrackWidth(Math.max(0, w - 20)); }; - const ro = new ResizeObserver(() => { - const w = el.getBoundingClientRect().width; - setTrackWidth(Math.max(0, w - 20)); - }); - + const ro = new ResizeObserver(measure); ro.observe(el); - setTrackWidth(el.getBoundingClientRect().width); + measure(); return () => ro.disconnect(); }, []); const clusters = useMemo(() => { - if (!events || events.length === 0) return []; + if (!visibleEvents || visibleEvents.length === 0) return []; - // If we can't measure yet, render singles (no clustering) to avoid weirdness if (!trackWidth) { - return events.map((e) => ({ - key: e._id, - type: "single", - leftPercent: dateToPercent(e.startDate), - items: [{ event: e, leftPercent: dateToPercent(e.startDate) }], - })); + return visibleEvents.map((e) => { + const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); + return { + key: e._id, + type: "single", + leftPercent, + items: [{ event: e, leftPercent }], + }; + }); } - const items = events + const items = visibleEvents .map((e) => { - const leftPercent = dateToPercent(e.startDate); + const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); const xPx = (leftPercent / 100) * trackWidth; return { event: e, leftPercent, xPx }; }) @@ -78,7 +98,6 @@ export default function TimelineRow({ for (let i = 1; i < items.length; i++) { const curr = items[i]; - if (curr.xPx - groupStartX <= CLUSTER_PX) { group.push(curr); } else { @@ -89,21 +108,37 @@ export default function TimelineRow({ } groups.push(group); - return groups.map((g, idx) => { - const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; - const leftPercent = (leftAvgPx / trackWidth) * 100; - - const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${g.length}`; + return groups + .map((g, idx) => { + const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; + const leftPercent = (leftAvgPx / trackWidth) * 100; + const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${g.length}`; + + return { + key, + type: g.length === 1 ? "single" : "cluster", + leftPercent, + leftAvgPx, + items: g, + }; + }) + .map((c, i, arr) => { + if (c.type !== "cluster") return { ...c, staggerDy: 0 }; + + const prev = arr[i - 1]; + if (prev && prev.type === "cluster") { + const dx = Math.abs(c.leftAvgPx - prev.leftAvgPx); + if (dx < CLUSTER_SEPARATION_PX) { + const dir = i % 2 === 0 ? -1 : 1; + return { ...c, staggerDy: dir * STAGGER_DY }; + } + } - return { - key, - type: g.length === 1 ? "single" : "cluster", - leftPercent, - items: g, - }; - }); - }, [events, trackWidth, layer._id]); + return { ...c, staggerDy: 0 }; + }); + }, [visibleEvents, trackWidth, layer._id, rangeStart, rangeEnd]); + // Close explosion if cluster disappears useEffect(() => { if (!explodedKey) return; const stillExists = clusters.some( @@ -134,10 +169,12 @@ export default function TimelineRow({
{layer.name} - {events.length} events + + {visibleEvents.length} / {events.length} events +
-
+
{clusters.map((c) => { @@ -155,11 +192,9 @@ export default function TimelineRow({ } const isExploded = explodedKey === c.key; - const clusterColor = getClusterColor(c.items); const count = c.items.length; - // sort for explode const explodedItems = c.items .slice() .sort((a, b) => a.xPx - b.xPx) @@ -167,14 +202,13 @@ export default function TimelineRow({ const hiddenCount = Math.max(0, count - explodedItems.length); - // cluster dot return (
toggleExplode(c.key)} @@ -187,8 +221,10 @@ export default function TimelineRow({ dataCount={count} colorOverride={clusterColor} leftOverride={c.leftPercent} + offset={{ dx: 0, dy: c.staggerDy ?? 0 }} className={dotStyles.cluster} /> + {isExploded && explodedItems.map((it, i) => { const sameX = explodedItems.filter( @@ -203,7 +239,8 @@ export default function TimelineRow({ dx = Math.round((j - mid) * EXPLODE_DX); } - const dy = i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY; + const baseDy = c.staggerDy ?? 0; + const dy = baseDy + (i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY); return ( s.yearRange); + + const ticks = useMemo(() => { + const span = endYear - startYear; + + const step = span <= 50 ? 10 : span <= 200 ? 25 : span <= 500 ? 50 : 100; + + const first = Math.ceil(startYear / step) * step; + + const arr = []; + for (let y = first; y <= endYear; y += step) { + arr.push(y); + } + + return arr; + }, [startYear, endYear]); + + const span = Math.max(1, endYear - startYear); + return ( -
- {YEARS.map((year) => ( -
- {year} -
- ))} +
+ {ticks.map((y) => { + const left = ((y - startYear) / span) * 100; + + return ( +
+
+ {y} +
+ ); + })}
); } diff --git a/frontend/src/features/timeline/components/ZoomBar.jsx b/frontend/src/features/timeline/components/ZoomBar.jsx new file mode 100644 index 000000000..59c213db0 --- /dev/null +++ b/frontend/src/features/timeline/components/ZoomBar.jsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import styles from "../styles/ZoomBar.module.css"; +import { useUiStore } from "../../../stores/uiStore"; + +function clampYear(n, min, max) { + if (Number.isNaN(n)) return min; + return Math.min(max, Math.max(min, n)); +} + +export default function ZoomBar({ minYear = 1500, maxYear = 2000 }) { + const [start, end] = useUiStore((s) => s.yearRange); + const setYearRange = useUiStore((s) => s.setYearRange); + const resetYearRange = useUiStore((s) => s.resetYearRange); + + // draft strings so typing feels normal + const [draftStart, setDraftStart] = useState(String(start)); + const [draftEnd, setDraftEnd] = useState(String(end)); + + // keep drafts in sync when store changes (e.g. Reset) + useEffect(() => { + setDraftStart(String(start)); + setDraftEnd(String(end)); + }, [start, end]); + + const commit = (nextStartStr, nextEndStr) => { + const nextStart = clampYear(Number(nextStartStr), minYear, maxYear); + const nextEnd = clampYear(Number(nextEndStr), minYear, maxYear); + + // keep order + const s = Math.min(nextStart, nextEnd); + const e = Math.max(nextStart, nextEnd); + + setYearRange([s, e]); + }; + + return ( +
+
+ + + +
+ + +
+ ); +} diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css index 93378e5e2..a5eda7154 100644 --- a/frontend/src/features/timeline/styles/EventDot.module.css +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -8,12 +8,10 @@ font: inherit; line-height: 1; cursor: pointer; - position: absolute; left: var(--left); top: 50%; transform: translate(-50%, -50%) translate(var(--dx, 0px), var(--dy, 0px)); - width: 10px; height: 10px; border-radius: 50%; @@ -56,16 +54,25 @@ .cluster::after { content: attr(data-count); position: absolute; - top: -14px; + top: -18px; left: 50%; transform: translateX(-50%); - font-size: 10px; + font-size: 9px; line-height: 1; - padding: 2px 6px; + padding: 3px 7px; border-radius: 999px; - background: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.95); color: #111; border: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); pointer-events: none; white-space: nowrap; } + +.trackHover .dot { + filter: brightness(1.1); +} + +.trackHover .cluster { + filter: brightness(1.05); +} diff --git a/frontend/src/features/timeline/styles/TimelineControls.module.css b/frontend/src/features/timeline/styles/TimelineControls.module.css new file mode 100644 index 000000000..d49a4a27c --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelineControls.module.css @@ -0,0 +1,58 @@ +.panel { + display: grid; + gap: 1rem; +} + +.block { + border: 1px solid #1a1a2e; + background: #0f0f1a; + border-radius: 10px; + padding: 0.75rem; +} + +.subheading { + margin: 0 0 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(232, 228, 217, 0.7); +} + +.hint { + margin: 0; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.6); +} + +.categoryList { + display: grid; + gap: 0.75rem; +} + +.categoryRow { + display: grid; + gap: 0.4rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.8); +} + +.categoryName { + color: rgba(232, 228, 217, 0.75); +} + +.select { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.5rem 0.65rem; + border-radius: 6px; + max-width: 100%; +} + +.select:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} diff --git a/frontend/src/features/timeline/styles/TimelineRow.module.css b/frontend/src/features/timeline/styles/TimelineRow.module.css index 9d6a8c379..e2b3e9198 100644 --- a/frontend/src/features/timeline/styles/TimelineRow.module.css +++ b/frontend/src/features/timeline/styles/TimelineRow.module.css @@ -1,5 +1,6 @@ .wrapper { - margin-bottom: 2rem; + margin-bottom: 2.25rem; + position: relative; } .label { @@ -12,29 +13,62 @@ display: flex; align-items: center; gap: 0.75rem; + text-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent); } .count { - color: #333; + color: rgba(232, 228, 217, 0.55); } .track { position: relative; - height: 48px; + height: 84px; + padding: 0 10px; background: #0f0f1a; border: 1px solid #1a1a2e; - border-radius: 2px; - width: 100%; - min-width: 0; + border-radius: 4px; overflow: hidden; - box-sizing: border-box; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .centerLine { position: absolute; top: 50%; - left: 0; - right: 0; + left: 10px; + right: 10px; height: 1px; - background: #1a1a2e; + background: rgba(232, 228, 217, 0.12); +} + +/* subtle layer glow */ +.wrapper::before { + content: ""; + position: absolute; + inset: -6px -10px; + border-radius: 8px; + background: radial-gradient( + circle at 50% 50%, + color-mix(in srgb, var(--accent) 18%, transparent), + transparent 70% + ); + opacity: 0.25; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.wrapper:hover::before { + opacity: 0.45; +} + +.wrapper:hover .track { + border-color: color-mix(in srgb, var(--accent) 35%, #1a1a2e); + box-shadow: + 0 0 0 1px rgba(232, 228, 217, 0.08), + 0 0 18px rgba(0, 0, 0, 0.35); +} + +.wrapper:hover .centerLine { + background: rgba(232, 228, 217, 0.18); } diff --git a/frontend/src/features/timeline/styles/YearAxis.module.css b/frontend/src/features/timeline/styles/YearAxis.module.css index ac82a660f..c09eb525b 100644 --- a/frontend/src/features/timeline/styles/YearAxis.module.css +++ b/frontend/src/features/timeline/styles/YearAxis.module.css @@ -1,15 +1,30 @@ .axis { position: relative; - height: 24px; + height: 32px; margin-top: 0.75rem; } +/* container */ .tick { position: absolute; left: var(--left); transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + user-select: none; +} + +/* tick mark */ +.line { + width: 1px; + height: 6px; + background: rgba(232, 228, 217, 0.35); +} + +.label { font-size: 0.65rem; - color: #333; + color: rgba(232, 228, 217, 0.7); font-family: "JetBrains Mono", monospace; - user-select: none; } diff --git a/frontend/src/features/timeline/styles/ZoomBar.module.css b/frontend/src/features/timeline/styles/ZoomBar.module.css new file mode 100644 index 000000000..74bdb0e34 --- /dev/null +++ b/frontend/src/features/timeline/styles/ZoomBar.module.css @@ -0,0 +1,61 @@ +.bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0.75rem 0 1rem; + flex-wrap: wrap; + min-width: 0; +} + +.group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.label { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: #b8b8b8; +} + +.input { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + font-size: 0.95rem; + caret-color: #e8e4d9; + padding: 0.45rem 0.6rem; + border-radius: 6px; + width: 7.5rem; + max-width: 100%; +} + +.input:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.reset { + background: transparent; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.45rem 0.75rem; + border-radius: 999px; + cursor: pointer; +} + +.reset:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +@media (max-width: 600px) { + .input { + width: 100%; + } +} diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx index 4d1147491..f435478e5 100644 --- a/frontend/src/layouts/AppLayout.jsx +++ b/frontend/src/layouts/AppLayout.jsx @@ -1,3 +1,5 @@ +import { useLayers } from "../features/layers/hooks"; +import TimelineControls from "../features/timeline/components/TimelineControls"; import { Outlet, Link } from "react-router-dom"; import { useEffect, useState } from "react"; import { useAuthStore } from "../stores/authStore"; @@ -7,6 +9,10 @@ export default function AppLayout() { const { user, logout, isAuthenticated } = useAuthStore(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isMobile, setIsMobile] = useState(() => window.innerWidth < 900); + const { data: layersData } = useLayers(); + const layers = layersData ?? []; + + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 900); @@ -22,9 +28,12 @@ export default function AppLayout() {
+
+ + {saveError ?

{saveError}

: null} +
+ +
+

My timelines

+ + {timelinesLoading ? ( +

Loading…

+ ) : timelinesError ? ( +

Could not load saved timelines.

+ ) : !timelines || timelines.length === 0 ? ( +

No saved timelines yet.

+ ) : ( +
    + {timelines.map((t) => ( +
  • + +
  • + ))} +
+ )} +
); } diff --git a/frontend/src/features/timeline/styles/TimelineControls.module.css b/frontend/src/features/timeline/styles/TimelineControls.module.css index d49a4a27c..267f51e01 100644 --- a/frontend/src/features/timeline/styles/TimelineControls.module.css +++ b/frontend/src/features/timeline/styles/TimelineControls.module.css @@ -56,3 +56,108 @@ outline: 3px solid rgba(232, 228, 217, 0.9); outline-offset: 2px; } + +.savedList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.5rem; +} + +.savedItem { + margin: 0; +} + +.savedButton { + width: 100%; + text-align: left; + display: block; + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: rgba(232, 228, 217, 0.9); + padding: 0.55rem 0.65rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.savedButton:hover { + border-color: rgba(232, 228, 217, 0.25); +} + +.savedButton:active { + transform: translateY(1px); +} + +.savedButton:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +/* Save current timeline */ +.saveRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + align-items: center; +} + +.input { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.55rem 0.65rem; + border-radius: 8px; + max-width: 100%; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; +} + +.input:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.saveButton { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: rgba(232, 228, 217, 0.9); + padding: 0.55rem 0.75rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.saveButton:hover { + border-color: rgba(232, 228, 217, 0.25); +} + +.saveButton:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.saveButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Screen-reader only label */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} From f633371621f246fff4b1acfec67a2261cbe3e93d Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Thu, 5 Mar 2026 14:55:01 +0100 Subject: [PATCH 26/36] feat(timeline): add visual zoom and synchronized year axis scrolling --- .../src/features/timeline/TimelinePage.jsx | 58 +++-- .../timeline/components/TimelineRow.jsx | 228 ++++++++++-------- .../features/timeline/components/YearAxis.jsx | 66 +++-- .../features/timeline/components/ZoomBar.jsx | 18 ++ .../timeline/styles/TimelineRow.module.css | 20 +- .../timeline/styles/YearAxis.module.css | 9 + .../timeline/styles/ZoomBar.module.css | 32 +++ frontend/src/stores/uiStore.js | 31 ++- 8 files changed, 322 insertions(+), 140 deletions(-) diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx index 70bd52780..cd3371c20 100644 --- a/frontend/src/features/timeline/TimelinePage.jsx +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -1,6 +1,5 @@ -import { useState, useEffect, useCallback } from "react"; -import { useLayers } from "../layers/hooks"; -import { useLayerEvents } from "../layers/hooks"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useLayers, useLayerEvents } from "../layers/hooks"; import TimelineRow from "./components/TimelineRow"; import YearAxis from "./components/YearAxis"; import EventPanel from "./components/EventPanel"; @@ -29,14 +28,12 @@ function ActiveLayer({ return

Failed to load {layer.name}

; return ( - <> - - + ); } @@ -50,12 +47,36 @@ export default function TimelinePage() { const [selectedEvent, setSelectedEvent] = useState(null); - // Default-select first layer once layers have loaded + const axisRef = useRef(null); + const rowsRef = useRef(null); + + // Sync scroll between axis and timeline + useEffect(() => { + const axis = axisRef.current; + const rows = rowsRef.current; + + if (!axis || !rows) return; + + const onAxisScroll = () => { + rows.scrollLeft = axis.scrollLeft; + }; + + const onRowsScroll = () => { + axis.scrollLeft = rows.scrollLeft; + }; + + axis.addEventListener("scroll", onAxisScroll); + rows.addEventListener("scroll", onRowsScroll); + + return () => { + axis.removeEventListener("scroll", onAxisScroll); + rows.removeEventListener("scroll", onRowsScroll); + }; + }, []); + + // Default first layer useEffect(() => { if (layers.length > 0 && selectedLayerIds.length === 0) { - // Use store setter logic (toggle first layer) - // avoid importing toggleLayer here by using setState directly: - // simplest: just set it in store via setState useUiStore.setState({ selectedLayerIds: [layers[0]._id] }); } }, [layers, selectedLayerIds.length]); @@ -65,6 +86,7 @@ export default function TimelinePage() { }, []); if (isLoading) return

Loading layers...

; + if (isError) return

Failed to load layers.

; @@ -77,7 +99,8 @@ export default function TimelinePage() {

-
+ {/* Timeline rows container */} +
{selectedLayerIds.length === 0 && (

Select a layer in the sidebar to begin. @@ -94,7 +117,10 @@ export default function TimelinePage() { category={categoryByLayerId[id] ?? ""} /> ))} +

+ {/* Year axis */} +
diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index 55f366cff..d7046b9a3 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -28,11 +28,13 @@ export default function TimelineRow({ onEventClick, }) { const [rangeStartYear, rangeEndYear] = useUiStore((s) => s.yearRange); + const visualZoom = useUiStore((s) => s.visualZoom); const rangeStart = useMemo( () => new Date(`${rangeStartYear}-01-01`), [rangeStartYear], ); + const rangeEnd = useMemo( () => new Date(`${rangeEndYear}-12-31`), [rangeEndYear], @@ -47,6 +49,7 @@ export default function TimelineRow({ }, [events, rangeStart, rangeEnd]); const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + const trackRef = useRef(null); const [trackWidth, setTrackWidth] = useState(0); @@ -69,10 +72,14 @@ export default function TimelineRow({ return () => ro.disconnect(); }, []); + const canvasWidth = useMemo(() => { + return trackWidth ? Math.round(trackWidth * Math.max(1, visualZoom)) : 0; + }, [trackWidth, visualZoom]); + const clusters = useMemo(() => { if (!visibleEvents || visibleEvents.length === 0) return []; - if (!trackWidth) { + if (!canvasWidth) { return visibleEvents.map((e) => { const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); return { @@ -87,17 +94,20 @@ export default function TimelineRow({ const items = visibleEvents .map((e) => { const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); - const xPx = (leftPercent / 100) * trackWidth; + const xPx = (leftPercent / 100) * canvasWidth; + return { event: e, leftPercent, xPx }; }) .sort((a, b) => a.xPx - b.xPx); const groups = []; + let group = [items[0]]; let groupStartX = items[0].xPx; for (let i = 1; i < items.length; i++) { const curr = items[i]; + if (curr.xPx - groupStartX <= CLUSTER_PX) { group.push(curr); } else { @@ -106,12 +116,15 @@ export default function TimelineRow({ groupStartX = curr.xPx; } } + groups.push(group); return groups .map((g, idx) => { const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; - const leftPercent = (leftAvgPx / trackWidth) * 100; + + const leftPercent = (leftAvgPx / canvasWidth) * 100; + const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${g.length}`; return { @@ -126,24 +139,28 @@ export default function TimelineRow({ if (c.type !== "cluster") return { ...c, staggerDy: 0 }; const prev = arr[i - 1]; + if (prev && prev.type === "cluster") { const dx = Math.abs(c.leftAvgPx - prev.leftAvgPx); + if (dx < CLUSTER_SEPARATION_PX) { const dir = i % 2 === 0 ? -1 : 1; + return { ...c, staggerDy: dir * STAGGER_DY }; } } return { ...c, staggerDy: 0 }; }); - }, [visibleEvents, trackWidth, layer._id, rangeStart, rangeEnd]); + }, [visibleEvents, canvasWidth, layer._id, rangeStart, rangeEnd]); - // Close explosion if cluster disappears useEffect(() => { if (!explodedKey) return; + const stillExists = clusters.some( (c) => c.key === explodedKey && c.type === "cluster", ); + if (!stillExists) setExplodedKey(null); }, [clusters, explodedKey]); @@ -151,17 +168,21 @@ export default function TimelineRow({ setExplodedKey((prev) => (prev === key ? null : key)); }, []); - // Click outside closes explosion useEffect(() => { if (!explodedKey) return; const onDocMouseDown = (e) => { const trackEl = trackRef.current; + if (!trackEl) return; - if (!trackEl.contains(e.target)) setExplodedKey(null); + + if (!trackEl.contains(e.target)) { + setExplodedKey(null); + } }; document.addEventListener("mousedown", onDocMouseDown); + return () => document.removeEventListener("mousedown", onDocMouseDown); }, [explodedKey]); @@ -169,112 +190,127 @@ export default function TimelineRow({
{layer.name} + {visibleEvents.length} / {events.length} events
-
-
+
+
+
- {clusters.map((c) => { - if (c.type === "single") { - const it = c.items[0]; - return ( - - ); - } + {clusters.map((c) => { + if (c.type === "single") { + const it = c.items[0]; - const isExploded = explodedKey === c.key; - const clusterColor = getClusterColor(c.items); - const count = c.items.length; - - const explodedItems = c.items - .slice() - .sort((a, b) => a.xPx - b.xPx) - .slice(0, MAX_EXPLODE); - - const hiddenCount = Math.max(0, count - explodedItems.length); - - return ( -
- toggleExplode(c.key)} - isSelected={false} - title={ - isExploded - ? "Click to collapse" - : `${count} events (click to expand)` - } - dataCount={count} - colorOverride={clusterColor} - leftOverride={c.leftPercent} - offset={{ dx: 0, dy: c.staggerDy ?? 0 }} - className={dotStyles.cluster} - /> - - {isExploded && - explodedItems.map((it, i) => { - const sameX = explodedItems.filter( - (x) => Math.abs(x.xPx - it.xPx) < 0.5, - ); - let dx = 0; - if (sameX.length > 1) { - const j = sameX.findIndex( - (x) => x.event._id === it.event._id, - ); - const mid = (sameX.length - 1) / 2; - dx = Math.round((j - mid) * EXPLODE_DX); - } + return ( + + ); + } + + const isExploded = explodedKey === c.key; + + const clusterColor = getClusterColor(c.items); - const baseDy = c.staggerDy ?? 0; - const dy = baseDy + (i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY); - - return ( - - ); - })} - - {isExploded && hiddenCount > 0 && ( + const count = c.items.length; + + const explodedItems = c.items + .slice() + .sort((a, b) => a.xPx - b.xPx) + .slice(0, MAX_EXPLODE); + + const hiddenCount = Math.max(0, count - explodedItems.length); + + return ( +
{}} + onClick={() => toggleExplode(c.key)} isSelected={false} - title={`+${hiddenCount} more (increase MAX_EXPLODE to show)`} - dataCount={`+${hiddenCount}`} - colorOverride={"#777"} + title={ + isExploded + ? "Click to collapse" + : `${count} events (click to expand)` + } + dataCount={count} + colorOverride={clusterColor} leftOverride={c.leftPercent} - offset={{ dx: 0, dy: -28 }} + offset={{ dx: 0, dy: c.staggerDy ?? 0 }} className={dotStyles.cluster} /> - )} -
- ); - })} + + {isExploded && + explodedItems.map((it, i) => { + const sameX = explodedItems.filter( + (x) => Math.abs(x.xPx - it.xPx) < 0.5, + ); + + let dx = 0; + + if (sameX.length > 1) { + const j = sameX.findIndex( + (x) => x.event._id === it.event._id, + ); + + const mid = (sameX.length - 1) / 2; + + dx = Math.round((j - mid) * EXPLODE_DX); + } + + const baseDy = c.staggerDy ?? 0; + + const dy = + baseDy + (i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY); + + return ( + + ); + })} + + {isExploded && hiddenCount > 0 && ( + {}} + isSelected={false} + title={`+${hiddenCount} more`} + dataCount={`+${hiddenCount}`} + colorOverride={"#777"} + leftOverride={c.leftPercent} + offset={{ dx: 0, dy: -28 }} + className={dotStyles.cluster} + /> + )} +
+ ); + })} +
); diff --git a/frontend/src/features/timeline/components/YearAxis.jsx b/frontend/src/features/timeline/components/YearAxis.jsx index afc039dd6..e15a61299 100644 --- a/frontend/src/features/timeline/components/YearAxis.jsx +++ b/frontend/src/features/timeline/components/YearAxis.jsx @@ -1,39 +1,63 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useUiStore } from "../../../stores/uiStore"; import styles from "../styles/YearAxis.module.css"; export default function YearAxis() { const [startYear, endYear] = useUiStore((s) => s.yearRange); + const visualZoom = useUiStore((s) => s.visualZoom); - const ticks = useMemo(() => { - const span = endYear - startYear; + const axisRef = useRef(null); + const [axisWidth, setAxisWidth] = useState(0); - const step = span <= 50 ? 10 : span <= 200 ? 25 : span <= 500 ? 50 : 100; + useEffect(() => { + if (!axisRef.current) return; + const el = axisRef.current; + + const measure = () => + setAxisWidth(Math.max(0, el.getBoundingClientRect().width)); + const ro = new ResizeObserver(measure); + ro.observe(el); + measure(); + + return () => ro.disconnect(); + }, []); + + const span = Math.max(1, endYear - startYear); + const ticks = useMemo(() => { + const step = span <= 50 ? 10 : span <= 200 ? 25 : span <= 500 ? 50 : 100; const first = Math.ceil(startYear / step) * step; const arr = []; - for (let y = first; y <= endYear; y += step) { - arr.push(y); - } - + for (let y = first; y <= endYear; y += step) arr.push(y); return arr; - }, [startYear, endYear]); + }, [startYear, endYear, span]); - const span = Math.max(1, endYear - startYear); + const canvasWidth = axisWidth + ? Math.round(axisWidth * Math.max(1, visualZoom)) + : 0; return ( -
- {ticks.map((y) => { - const left = ((y - startYear) / span) * 100; - - return ( -
-
- {y} -
- ); - })} +
+
+ {ticks.map((y) => { + const leftPx = ((y - startYear) / span) * canvasWidth; + + return ( +
+
+ {y} +
+ ); + })} +
); } diff --git a/frontend/src/features/timeline/components/ZoomBar.jsx b/frontend/src/features/timeline/components/ZoomBar.jsx index 59c213db0..534a27bb5 100644 --- a/frontend/src/features/timeline/components/ZoomBar.jsx +++ b/frontend/src/features/timeline/components/ZoomBar.jsx @@ -11,6 +11,9 @@ export default function ZoomBar({ minYear = 1500, maxYear = 2000 }) { const [start, end] = useUiStore((s) => s.yearRange); const setYearRange = useUiStore((s) => s.setYearRange); const resetYearRange = useUiStore((s) => s.resetYearRange); + const visualZoom = useUiStore((s) => s.visualZoom); + const setVisualZoom = useUiStore((s) => s.setVisualZoom); + const resetVisualZoom = useUiStore((s) => s.resetVisualZoom); // draft strings so typing feels normal const [draftStart, setDraftStart] = useState(String(start)); @@ -73,11 +76,26 @@ export default function ZoomBar({ minYear = 1500, maxYear = 2000 }) {
+ + @@ -47,7 +51,11 @@ export default function AppLayout() { {isAuthenticated() ? ( <> {user?.email} - @@ -60,6 +68,7 @@ export default function AppLayout() { > Log in + - {/* Overlay (mobile) */} - {isMobile && isSidebarOpen && ( - + +
+ {cluster.items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx index d7046b9a3..c93d8c74b 100644 --- a/frontend/src/features/timeline/components/TimelineRow.jsx +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -3,12 +3,13 @@ import dotStyles from "../styles/EventDot.module.css"; import styles from "../styles/TimelineRow.module.css"; import { LAYER_ACCENT, CATEGORY_COLORS, dateToPercent } from "../constants"; import EventDot from "./EventDot"; +import ClusterModal from "./ClusterModal"; import { useUiStore } from "../../../stores/uiStore"; -const CLUSTER_PX = 8; -const EXPLODE_DY = 12; -const EXPLODE_DX = 10; -const MAX_EXPLODE = 60; +const CLUSTER_PX = 16; +const EXPLODE_DY = 32; +const EXPLODE_DX = 20; +const MAX_EXPLODE = 0; // Disable expansion on timeline, use modal instead const CLUSTER_SEPARATION_PX = 18; const STAGGER_DY = 10; @@ -54,6 +55,7 @@ export default function TimelineRow({ const [trackWidth, setTrackWidth] = useState(0); const [explodedKey, setExplodedKey] = useState(null); + const [selectedCluster, setSelectedCluster] = useState(null); useEffect(() => { if (!trackRef.current) return; @@ -168,6 +170,14 @@ export default function TimelineRow({ setExplodedKey((prev) => (prev === key ? null : key)); }, []); + const openClusterModal = useCallback((cluster) => { + setSelectedCluster(cluster); + }, []); + + const closeClusterModal = useCallback(() => { + setSelectedCluster(null); + }, []); + useEffect(() => { if (!explodedKey) return; @@ -240,13 +250,9 @@ export default function TimelineRow({ startDate: new Date(), category: "mixed", }} - onClick={() => toggleExplode(c.key)} + onClick={() => openClusterModal(c)} isSelected={false} - title={ - isExploded - ? "Click to collapse" - : `${count} events (click to expand)` - } + title={`${count} events (click to view list)`} dataCount={count} colorOverride={clusterColor} leftOverride={c.leftPercent} @@ -311,6 +317,12 @@ export default function TimelineRow({ ); })}
+ +
); diff --git a/frontend/src/features/timeline/styles/ClusterModal.module.css b/frontend/src/features/timeline/styles/ClusterModal.module.css new file mode 100644 index 000000000..098516e14 --- /dev/null +++ b/frontend/src/features/timeline/styles/ClusterModal.module.css @@ -0,0 +1,93 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: white; + border-radius: 8px; + max-width: 500px; + max-height: 70vh; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; +} + +.header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.closeBtn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.closeBtn:hover { + background: #f0f0f0; +} + +.list { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.eventItem { + display: block; + width: 100%; + padding: 12px 20px; + border: none; + background: none; + text-align: left; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background 0.1s; +} + +.eventItem:hover { + background: #f8f8f8; +} + +.eventItem:last-child { + border-bottom: none; +} + +.title { + font-weight: 500; + color: #333; + display: block; +} + +.date { + font-size: 14px; + color: #666; + margin-top: 2px; +} \ No newline at end of file From 7bb5dd3680ea698656a7489df096226dde26022f Mon Sep 17 00:00:00 2001 From: Sara Enderborg Date: Sun, 8 Mar 2026 19:50:29 +0100 Subject: [PATCH 36/36] "fix: increase event number font size for better accessibility --- frontend/src/features/timeline/styles/EventDot.module.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css index e926853aa..73d049dce 100644 --- a/frontend/src/features/timeline/styles/EventDot.module.css +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -18,7 +18,6 @@ z-index: 1; } -/* visible dot */ .dot::before { content: ""; position: absolute; @@ -70,7 +69,7 @@ top: -10px; left: 50%; transform: translateX(-50%); - font-size: 9px; + font-size: 12px; line-height: 1; padding: 3px 7px; border-radius: 999px;