Compare commits
3 commits
211792acdb
...
c8187f3f5e
Author | SHA1 | Date | |
---|---|---|---|
|
c8187f3f5e | ||
|
1789731ab9 | ||
|
070c3ef848 |
6 changed files with 527 additions and 113 deletions
41
package-lock.json
generated
41
package-lock.json
generated
|
@ -9,9 +9,12 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||||
|
"@sveu/browser": "^1.0.1",
|
||||||
"@tsconfig/svelte": "^5.0.2",
|
"@tsconfig/svelte": "^5.0.2",
|
||||||
|
"sass": "^1.77.2",
|
||||||
"svelte": "^4.2.12",
|
"svelte": "^4.2.12",
|
||||||
"svelte-check": "^3.6.7",
|
"svelte-check": "^3.6.7",
|
||||||
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
|
@ -728,6 +731,21 @@
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@sveu/browser": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sveu/browser/-/browser-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Mz6Vx7erVabjL+yIM/9h9lqRjEbCiIRKjH9KD1VuR3xTTJdMiljoeWph9B+shg0n2VLhY2tz8PvTu6H3eqhF8g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@sveu/shared": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sveu/shared": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sveu/shared/-/shared-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-waMJ+UXD5NV/M7L6lmXigY9Ry9TwhcBz+QDbYsDcq92bL7nlRUeh3ckvvAnxw1Nzw0T2JeEgmvZZblHuAHJL9Q==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/svelte": {
|
"node_modules/@tsconfig/svelte": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz",
|
||||||
|
@ -1095,6 +1113,12 @@
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/immutable": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
|
@ -1546,6 +1570,23 @@
|
||||||
"rimraf": "^2.5.2"
|
"rimraf": "^2.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sass": {
|
||||||
|
"version": "1.77.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.2.tgz",
|
||||||
|
"integrity": "sha512-eb4GZt1C3avsX3heBNlrc7I09nyT00IUuo4eFhAbeXWU2fvA7oXI53SxODVAA+zgZCk9aunAZgO+losjR3fAwA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": ">=3.0.0 <4.0.0",
|
||||||
|
"immutable": "^4.0.0",
|
||||||
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sass": "sass.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sorcery": {
|
"node_modules/sorcery": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz",
|
||||||
|
|
|
@ -11,9 +11,12 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||||
|
"@sveu/browser": "^1.0.1",
|
||||||
"@tsconfig/svelte": "^5.0.2",
|
"@tsconfig/svelte": "^5.0.2",
|
||||||
|
"sass": "^1.77.2",
|
||||||
"svelte": "^4.2.12",
|
"svelte": "^4.2.12",
|
||||||
"svelte-check": "^3.6.7",
|
"svelte-check": "^3.6.7",
|
||||||
|
"svelte-preprocess": "^5.1.4",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
|
|
345
src/Viz.svelte
345
src/Viz.svelte
|
@ -1,62 +1,110 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import parsed_requests from "/data/parsed_requests.json";
|
import parsed_requests from "/data/parsed_requests.json";
|
||||||
|
import { draw, slide } from "svelte/transition";
|
||||||
|
import { fps } from "@sveu/browser";
|
||||||
|
|
||||||
|
import { All, Scene, Timeline } from "./scenes/Scenes";
|
||||||
|
import {
|
||||||
|
type Location,
|
||||||
|
type Movement,
|
||||||
|
type Item,
|
||||||
|
type Motion,
|
||||||
|
get_path_d,
|
||||||
|
type Data,
|
||||||
|
type VizData,
|
||||||
|
type Log,
|
||||||
|
} from "./lib/types";
|
||||||
|
|
||||||
|
// these are passed from main.ts (or vice versaas)
|
||||||
|
export let width = 0;
|
||||||
|
export let height = 0;
|
||||||
|
export let dev: Boolean = false;
|
||||||
|
|
||||||
|
const _fps = fps();
|
||||||
|
|
||||||
|
const node_positions_percentages = {
|
||||||
|
APM: [13.829853909464873, 11.277305564859935],
|
||||||
|
IWO: [82.20349794238683, 92.52914963452248],
|
||||||
|
BC: [13.829853909464873, 10.235638898193269],
|
||||||
|
ARTIS: [37.36555349794344, 14.373622178489617],
|
||||||
|
BHBPU: [18.895388888889407, 8.009932889956616],
|
||||||
|
CEDLA: [32.88047119341627, 21.36735134591498],
|
||||||
|
UBGW: [7.420491769548115, 13.544746441851984],
|
||||||
|
IVIR: [33.838187242798554, 17.768099884380575],
|
||||||
|
OTM: [80.81460905349795, 93.83123296785581],
|
||||||
|
JB: [34.30115020576152, 19.538933217713907],
|
||||||
|
REC: [32.38571810699577, 16.229534855698518],
|
||||||
|
AMC: [81.82347530864213, 90.12737486852306],
|
||||||
|
PCHH: [10.320697530863542, 3.927002725110166],
|
||||||
|
BETA: [78.14333127572034, 26.257884003417413],
|
||||||
|
UBB: [10.198269547325893, 16.40932977518532],
|
||||||
|
};
|
||||||
|
|
||||||
|
const node_positions = Object.fromEntries(
|
||||||
|
Object.entries(node_positions_percentages).map(([k, p]) => [
|
||||||
|
k,
|
||||||
|
[(p[0] / 100) * width, (p[1] / 100) * height],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
$: console.log(parsed_requests);
|
$: console.log(parsed_requests);
|
||||||
|
|
||||||
// TODO: pass these from main.ts (or vice versaas)
|
|
||||||
const width = 2160;
|
|
||||||
const height = 3840;
|
|
||||||
|
|
||||||
// preprocess data
|
// preprocess data
|
||||||
const nodes = parsed_requests.nodes;
|
const _nodes: Location[] = parsed_requests.nodes;
|
||||||
const edges: Array<Object> = parsed_requests.edges;
|
const _requests: Array<Object> = parsed_requests.edges;
|
||||||
|
|
||||||
|
const items = new Map<string, Item>();
|
||||||
|
const movements: Array<Movement> = [];
|
||||||
|
|
||||||
// filter nodes with only having both Latitude and Longitude.
|
// filter nodes with only having both Latitude and Longitude.
|
||||||
// then map these coordinates to the canvas space
|
// then map these coordinates to the canvas space
|
||||||
const valid_nodes = nodes
|
const locations = new Map(_nodes
|
||||||
.filter((n) => n.lat && n.lon)
|
.filter((n) => n.lat && n.lon)
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
// const { x: x1, y: y1 } = latLonToOffsets(node.lat, node.lon, 100000, 100000)
|
node["x"] = node_positions[node.code][0];
|
||||||
const { x: x1, y: y1 } = latLonToOffsets(
|
node["y"] = node_positions[node.code][1];
|
||||||
node.lat,
|
|
||||||
node.lon,
|
|
||||||
100000,
|
|
||||||
100000,
|
|
||||||
);
|
|
||||||
console.log(x1, y1);
|
|
||||||
const x2 = inverse_lerp(1358, 1380, x1);
|
|
||||||
const y2 = inverse_lerp(32862, 32900, y1);
|
|
||||||
|
|
||||||
const margin = 200;
|
|
||||||
const x = x2 * (width - 2 * margin) + margin;
|
|
||||||
const y = y2 * (height - 2 * margin) + margin;
|
|
||||||
// console.log(x2, y2)
|
|
||||||
// console.log(node)
|
|
||||||
|
|
||||||
node["x"] = x;
|
|
||||||
node["y"] = y;
|
|
||||||
return node;
|
return node;
|
||||||
});
|
})
|
||||||
|
.map((d) => [d["name"], d])
|
||||||
|
);
|
||||||
|
|
||||||
// create an index to access the node objects by their name
|
|
||||||
const nodeMap = Object.fromEntries(valid_nodes.map((d) => [d["name"], d]));
|
|
||||||
const edgeIndexBarcode = buildIndex(edges, "Barcode");
|
|
||||||
// console.log(edgeIndexBarcode);
|
|
||||||
|
|
||||||
const movements = edges
|
_requests
|
||||||
|
// remove entries that stay at the same place
|
||||||
|
.filter((n) => n['Owning Library Name'] != n['Pickup Location'])
|
||||||
.filter(
|
.filter(
|
||||||
(l) =>
|
(l) =>
|
||||||
nodeMap[l["Owning Library Name"]] &&
|
locations.has(l["Owning Library Name"]) &&
|
||||||
nodeMap[l["Pickup Location"]],
|
locations.has(l["Pickup Location"]),
|
||||||
)
|
)
|
||||||
.map((l, idx) => {
|
.forEach((r, idx) => {
|
||||||
l.source = nodeMap[l["Owning Library Name"]];
|
const identifier: String = r["Barcode"];
|
||||||
l.target = nodeMap[l["Pickup Location"]];
|
if (!items.has(identifier)) {
|
||||||
l.nr = idx;
|
items.set(identifier, {
|
||||||
return l;
|
title: r["Title (Complete)"],
|
||||||
|
MMS: r["MMS Id"],
|
||||||
|
Barcode: r["Barcode"],
|
||||||
|
Publisher: r["Publisher"],
|
||||||
|
_original: r,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
let movement: Movement = {
|
||||||
|
source: locations.get(r["Owning Library Name"]),
|
||||||
|
target: locations.get(r["Pickup Location"]),
|
||||||
|
nr: idx,
|
||||||
|
item: items.get(identifier),
|
||||||
|
_original: r,
|
||||||
|
d: "", // bit of a hacky workaround, refactor in object
|
||||||
|
};
|
||||||
|
movement.d = get_path_d(movement);
|
||||||
|
movements.push(movement);
|
||||||
|
});
|
||||||
|
console.log(items, movements);
|
||||||
|
|
||||||
function degreesToRadians(degrees) {
|
// const edgeIndexBarcode = buildIndex(edges, "Barcode");
|
||||||
|
// console.log(edgeIndexBarcode);
|
||||||
|
|
||||||
|
function degreesToRadians(degrees: number) {
|
||||||
return (degrees * Math.PI) / 180;
|
return (degrees * Math.PI) / 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +124,7 @@
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerp(X, Y, t) {
|
function lerp(X: number, Y: number, t: number) {
|
||||||
return X * t + Y * (1 - t);
|
return X * t + Y * (1 - t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,104 +136,174 @@
|
||||||
[index: string]: Array<number>;
|
[index: string]: Array<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIndex(objectList: Array<Object>, field: string): IndexObject {
|
// function buildIndex(objectList: Array<Object>, field: string): IndexObject {
|
||||||
const index = {};
|
// const index = {};
|
||||||
for (const idx in objectList) {
|
// for (const idx in objectList) {
|
||||||
const v = objectList[idx][field];
|
// const v = objectList[idx][field];
|
||||||
if (!index.hasOwnProperty(v)) {
|
// if (!index.hasOwnProperty(v)) {
|
||||||
index[v] = [idx];
|
// index[v] = [idx];
|
||||||
} else {
|
// } else {
|
||||||
index[v].push(idx);
|
// index[v].push(idx);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return index;
|
// return index;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const occurences = new Map<Item, Movement[]>();
|
||||||
|
movements.forEach((movement, idx) => {
|
||||||
|
let movements = occurences.get(movement.item) ?? []
|
||||||
|
movements.push(movement)
|
||||||
|
occurences.set(movement.item, movements);
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: Data = {
|
||||||
|
locations: locations,
|
||||||
|
items: items,
|
||||||
|
movements: movements,
|
||||||
|
occurences: occurences
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_path_d(movement) {
|
let drawn_motions = writable(<Motion[]>[]); //.filter((m, i) => i < 100);
|
||||||
const m = movement;
|
$: opacity = Math.max(0.055, Math.min(1, 200 / $drawn_motions.length));
|
||||||
let sourceX, targetX, midX, dx, dy, angle;
|
let overlay_motions = writable(<Motion[]>[]); //.filter((m, i) => i < 100);
|
||||||
|
let events = writable(<Log[]>[]); //.filter((m, i) => i < 100);
|
||||||
|
|
||||||
// This mess makes the arrows exactly perfect.
|
const viz_data: VizData = {
|
||||||
// thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4
|
drawn_motions: drawn_motions,
|
||||||
if (m.source.x < m.target.x) {
|
overlay_motions: overlay_motions,
|
||||||
sourceX = m.source.x;
|
events: events,
|
||||||
targetX = m.target.x;
|
|
||||||
} else if (m.target.x < m.source.x) {
|
|
||||||
targetX = m.target.x;
|
|
||||||
sourceX = m.source.x;
|
|
||||||
} else if (m.target.isCircle) {
|
|
||||||
targetX = sourceX = m.target.x;
|
|
||||||
} else if (m.source.isCircle) {
|
|
||||||
targetX = sourceX = m.source.x;
|
|
||||||
} else {
|
|
||||||
midX = (m.source.x + m.target.x) / 2;
|
|
||||||
if (midX > m.target.x) {
|
|
||||||
midX = m.target.x;
|
|
||||||
} else if (midX > m.source.x) {
|
|
||||||
midX = m.source.x;
|
|
||||||
} else if (midX < m.target.x) {
|
|
||||||
midX = m.target.x;
|
|
||||||
} else if (midX < m.source.x) {
|
|
||||||
midX = m.source.x;
|
|
||||||
}
|
|
||||||
targetX = sourceX = midX;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dx = targetX - sourceX;
|
import { onMount, onDestroy } from "svelte";
|
||||||
dy = m.target.y - m.source.y;
|
import { writable } from "svelte/store";
|
||||||
angle = Math.atan2(dx, dy);
|
|
||||||
|
|
||||||
var srcSize = 5; //_mapGraph.getSizeForNode(m.source);
|
let scenes = [All, Timeline];
|
||||||
var tgtSize = 5; //_mapGraph.getSizeForNode(m.target);
|
let currentSceneI = 0;
|
||||||
|
let currentScene: Scene;
|
||||||
|
|
||||||
// Compute the line endpoint such that the arrow
|
function nextScene() {
|
||||||
// it not in the center, but rather slightly out of it
|
if (currentScene) {
|
||||||
// use a small ofset for the angle to compensate roughly for the curve
|
currentScene.stop();
|
||||||
m.sourceX = sourceX + Math.sin(angle + 0.5) * srcSize;
|
|
||||||
m.targetX = targetX - Math.sin(angle - 0.5) * tgtSize;
|
|
||||||
m.sourceY = m.source.y + Math.cos(angle + 0.5) * srcSize;
|
|
||||||
m.targetY = m.target.y - Math.cos(angle - 0.5) * tgtSize;
|
|
||||||
|
|
||||||
// find radius of arc based on distance between points
|
|
||||||
// add a jitter to spread out the lines when links are stacked
|
|
||||||
const dr = Math.sqrt(dx * dx + dy * dy) * (0.7 + Math.random() * 0.6);
|
|
||||||
|
|
||||||
// "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y
|
|
||||||
return `M ${m.sourceX},${m.sourceY} A ${dr},${dr} 0 0,1 ${m.targetX},${m.targetY}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(movements);
|
console.log("Next scene", currentSceneI);
|
||||||
|
currentScene = new scenes[currentSceneI](
|
||||||
|
data,
|
||||||
|
viz_data,
|
||||||
|
nextScene,
|
||||||
|
);
|
||||||
|
currentSceneI = (currentSceneI + 1) % scenes.length;
|
||||||
|
}
|
||||||
|
|
||||||
let drawn_movements = movements;
|
let paused = false;
|
||||||
|
onMount(() => {
|
||||||
|
let interval = null;
|
||||||
|
nextScene();
|
||||||
|
// interval = setInterval(() => {
|
||||||
|
// if (paused) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// // TODO: change scene
|
||||||
|
// // timeElapsed += 1;
|
||||||
|
// const n = Math.ceil(Math.random() * 8);
|
||||||
|
// const start_i = Math.floor((movements.length - n) * Math.random());
|
||||||
|
// drawn_movements = movements.filter(
|
||||||
|
// (m, i) => i >= start_i && i < start_i + n,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // if (remainingTime === 0) {
|
||||||
|
// // clearInterval(interval);
|
||||||
|
// // deleteTimer();
|
||||||
|
// // }
|
||||||
|
// }, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
currentScene.stop();
|
||||||
|
// clearInterval(interval);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>library of <span id="motiontitle">motions</span></h1>
|
<h1>library of <span id="motiontitle">motions</span></h1>
|
||||||
<div id="about">
|
<div id="about">
|
||||||
Work by <em>Ruben van de Ven</em> for the
|
<span>Work by <em>Ruben van de Ven</em> for the
|
||||||
<em>University of Amsterdam Library</em>. Fonts by
|
<em>University of Amsterdam Library</em>.</span>
|
||||||
<em>Open Source Publishing</em>, map by <em>OpenStreetMap</em>.
|
<span>Fonts by <em>Open Source Publishing</em>.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svg {width} {height}>
|
<svg {width} {height}>
|
||||||
<g id="movements">
|
<g id="movements">
|
||||||
{#each drawn_movements as m}
|
{#each $drawn_motions as m}
|
||||||
<path d={get_path_d(m)}></path>
|
<path
|
||||||
|
in:draw|global={{ duration: m.duration }}
|
||||||
|
d={m.movement.d}
|
||||||
|
style="opacity: {opacity}"
|
||||||
|
></path>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
<g id="overlay_motions">
|
||||||
|
{#each $overlay_motions as m}
|
||||||
|
<path in:draw|global={{ duration: m.duration }} d={m.movement.d}
|
||||||
|
></path>
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
<g id="libraries">
|
<g id="libraries">
|
||||||
{#each valid_nodes as node}
|
{#each locations.values() as node}
|
||||||
<g id={node.code} transform="translate({node.x}, {node.y})">
|
<g id={node.code} transform="translate({node.x}, {node.y})">
|
||||||
<circle r="5"></circle>
|
<circle r="5"></circle>
|
||||||
<circle r="20"></circle>
|
<circle r="20"></circle>
|
||||||
<text class="nodeTitle" y="4" x="35">{node.name}</text>
|
<text class="nodeTitle" y="13" x="35">{node.name}</text>
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{#if currentScene?.rendered_elements.indexOf("timeline") >= 0}
|
||||||
|
<div id="timeline">
|
||||||
|
{#each $events as m}
|
||||||
|
<div class="entry" in:slide={{ duration: 200 }}>
|
||||||
|
<!-- {m['Title (Complete)']} -->
|
||||||
|
<span class="date"
|
||||||
|
>{m.movement._original["Request Completion Date"]}</span
|
||||||
|
>
|
||||||
|
<span class="location">{m.movement.target.name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if dev}
|
||||||
|
<div id="fps">{$_fps}</div>
|
||||||
|
<div id="controls">
|
||||||
|
<button on:click={nextScene}>▷</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
|
* {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
#timeline {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
#fps {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 9999;
|
||||||
|
font-size: 30px;
|
||||||
|
/* color: white; */
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 40px;
|
||||||
|
z-index: 9999;
|
||||||
|
font-size: 30px;
|
||||||
|
/* color: white; */
|
||||||
|
}
|
||||||
#about {
|
#about {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 8pt;
|
font-size: 8pt;
|
||||||
|
@ -193,6 +311,10 @@
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 450px;
|
width: 450px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
|
span{
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#libraries circle:first-child {
|
#libraries circle:first-child {
|
||||||
fill: white;
|
fill: white;
|
||||||
|
@ -204,10 +326,15 @@
|
||||||
stroke-width: 10;
|
stroke-width: 10;
|
||||||
}
|
}
|
||||||
path {
|
path {
|
||||||
stroke: #bc89ff10;
|
stroke: #bc89ff;
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
#overlay_motions path {
|
||||||
|
stroke: red;
|
||||||
|
stroke-width: 4;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
text {
|
text {
|
||||||
fill: white;
|
fill: white;
|
||||||
font-size: 30pt;
|
font-size: 30pt;
|
||||||
|
|
107
src/lib/types.ts
Normal file
107
src/lib/types.ts
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export type Location = Object;
|
||||||
|
|
||||||
|
// disambiguated (physical or online) library object
|
||||||
|
export type Item = {
|
||||||
|
title: String,
|
||||||
|
MMS: String,
|
||||||
|
Barcode: String, // String because of preceding 0
|
||||||
|
Publisher: String,
|
||||||
|
_original: Object
|
||||||
|
}
|
||||||
|
|
||||||
|
// A movement of the object (Request in Alma Analytics)
|
||||||
|
export type Movement = {
|
||||||
|
nr: number, // unique identifier in the set
|
||||||
|
source: Location,
|
||||||
|
target: Location,
|
||||||
|
item: Item,
|
||||||
|
_original: Object
|
||||||
|
// also contains additional request data (see requests.csv)
|
||||||
|
d: String
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Occurences = Map<Item, Movement[]>
|
||||||
|
|
||||||
|
export interface Data {
|
||||||
|
locations: Map<String, Location>,
|
||||||
|
items: Map<string, Item>,
|
||||||
|
movements: Movement[],
|
||||||
|
occurences: Occurences
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// An event to trigger drawing an Edge
|
||||||
|
export type Motion = {
|
||||||
|
duration: number,
|
||||||
|
movement: Movement,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Log = {
|
||||||
|
date: Date,
|
||||||
|
title: String,
|
||||||
|
description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Type used by the scenes with all reactive Writables for drawable objects
|
||||||
|
export type VizData = {
|
||||||
|
drawn_motions: Writable<Motion[]>,
|
||||||
|
overlay_motions: Writable<Motion[]>,
|
||||||
|
events: Writable<Log[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_path_d(movement: Movement) {
|
||||||
|
const m = movement;
|
||||||
|
// console.log(m)
|
||||||
|
let sourceX, targetX, midX, dx, dy, angle;
|
||||||
|
|
||||||
|
// This mess makes the arrows exactly perfect.
|
||||||
|
// thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4
|
||||||
|
if (m.source.x < m.target.x) {
|
||||||
|
sourceX = m.source.x;
|
||||||
|
targetX = m.target.x;
|
||||||
|
} else if (m.target.x < m.source.x) {
|
||||||
|
targetX = m.target.x;
|
||||||
|
sourceX = m.source.x;
|
||||||
|
} else if (m.target.isCircle) {
|
||||||
|
targetX = sourceX = m.target.x;
|
||||||
|
} else if (m.source.isCircle) {
|
||||||
|
targetX = sourceX = m.source.x;
|
||||||
|
} else {
|
||||||
|
midX = (m.source.x + m.target.x) / 2;
|
||||||
|
if (midX > m.target.x) {
|
||||||
|
midX = m.target.x;
|
||||||
|
} else if (midX > m.source.x) {
|
||||||
|
midX = m.source.x;
|
||||||
|
} else if (midX < m.target.x) {
|
||||||
|
midX = m.target.x;
|
||||||
|
} else if (midX < m.source.x) {
|
||||||
|
midX = m.source.x;
|
||||||
|
}
|
||||||
|
targetX = sourceX = midX;
|
||||||
|
}
|
||||||
|
|
||||||
|
dx = targetX - sourceX;
|
||||||
|
dy = m.target.y - m.source.y;
|
||||||
|
angle = Math.atan2(dx, dy);
|
||||||
|
|
||||||
|
var srcSize = 5; //_mapGraph.getSizeForNode(m.source);
|
||||||
|
var tgtSize = 5; //_mapGraph.getSizeForNode(m.target);
|
||||||
|
|
||||||
|
// Compute the line endpoint such that the arrow
|
||||||
|
// it not in the center, but rather slightly out of it
|
||||||
|
// use a small ofset for the Movemente to compensate roughly for the curve
|
||||||
|
let m_sourceX = sourceX + Math.sin(angle + 0.5) * srcSize;
|
||||||
|
let m_targetX = targetX - Math.sin(angle - 0.5) * tgtSize;
|
||||||
|
let m_sourceY = m.source.y + Math.cos(angle + 0.5) * srcSize;
|
||||||
|
let m_targetY = m.target.y - Math.cos(angle - 0.5) * tgtSize;
|
||||||
|
|
||||||
|
// find radius of arc based on distance between points
|
||||||
|
// add a jitter to spread out the lines when links are stacked
|
||||||
|
const dr = Math.sqrt(dx * dx + dy * dy) * (0.7 + Math.random() * 0.5);
|
||||||
|
|
||||||
|
// "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y
|
||||||
|
return `M ${m_sourceX},${m_sourceY} A ${dr},${dr} 0 0,1 ${m_targetX},${m_targetY}`;
|
||||||
|
}
|
|
@ -7,7 +7,15 @@ document.getElementById('canvas')!.style.width = `${width}px`;
|
||||||
document.getElementById('canvas')!.style.height = `${height}px`;
|
document.getElementById('canvas')!.style.height = `${height}px`;
|
||||||
|
|
||||||
const app = new Viz({
|
const app = new Viz({
|
||||||
|
// see https://svelte.dev/docs/client-side-component-api
|
||||||
target: document.getElementById('viz')!,
|
target: document.getElementById('viz')!,
|
||||||
|
props: {
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
dev: true
|
||||||
|
},
|
||||||
|
// intro: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|
128
src/scenes/Scenes.ts
Normal file
128
src/scenes/Scenes.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import type { Data, Item, Motion, Movement, VizData } from "../lib/types";
|
||||||
|
|
||||||
|
export class Scene {
|
||||||
|
rendered_elements: String[] = []
|
||||||
|
stop() {
|
||||||
|
console.log("TODO: stop timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class All extends Scene {
|
||||||
|
allMovements: Movement[]
|
||||||
|
motions: Writable<Motion[]>
|
||||||
|
interval: number
|
||||||
|
step: number = 0
|
||||||
|
nextScene: CallableFunction
|
||||||
|
locationCounts = new Map<Location, { in: number, out: number }>()
|
||||||
|
|
||||||
|
constructor(data: Data, viz_data: VizData, nextScene: CallableFunction) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.nextScene = nextScene
|
||||||
|
|
||||||
|
// start setInterval to trigger additions per 100 or so to drawn_movements (rendered on map)
|
||||||
|
// when done, trigger parent.done()
|
||||||
|
this.allMovements = data.movements;
|
||||||
|
|
||||||
|
this.motions = viz_data.drawn_motions
|
||||||
|
// TODO: group by hour and have it last nr-of-hours * interval
|
||||||
|
// TODO: then, add a timeline
|
||||||
|
// TODO: then, add an overlay with relevant events, e.g.
|
||||||
|
// TODO: when a the oldest object was moved, the newest object,
|
||||||
|
// TODO: when a an object wasn't moved for very long,
|
||||||
|
// TODO: .. and any other filter
|
||||||
|
// TODO: then finish with a summary per location
|
||||||
|
// TODO: .. (or add these in small prints as counters under the location names)
|
||||||
|
this.interval = setInterval(this.tick.bind(this), 500);
|
||||||
|
|
||||||
|
// clear the motions when kicking off:
|
||||||
|
this.motions.update(items => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
const n = 10
|
||||||
|
if (this.step >= this.allMovements.length) {
|
||||||
|
console.log('this', 'done')
|
||||||
|
// todo: ease out all entries
|
||||||
|
this.nextScene()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let movements: Movement[] = this.allMovements.slice(this.step, this.step + n);
|
||||||
|
|
||||||
|
// duration 5000 + Math.random() * 10000
|
||||||
|
let motions: Motion[] = movements.map((m) => ({ duration: 5000, movement: m }));
|
||||||
|
|
||||||
|
this.motions.update(items => ([...items, ...motions]))
|
||||||
|
this.step += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene_type = 'dedicated' //dedicated (like timeline) or overlay (like oldest)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Timeline extends Scene {
|
||||||
|
movements: Movement[]
|
||||||
|
item: Item
|
||||||
|
motions: Writable<Motion[]>
|
||||||
|
interval: number
|
||||||
|
step: number = 0
|
||||||
|
nextScene: CallableFunction
|
||||||
|
locationCounts = new Map<Location, { in: number, out: number }>()
|
||||||
|
|
||||||
|
constructor(data: Data, viz_data: VizData, nextScene: CallableFunction) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.nextScene = nextScene
|
||||||
|
|
||||||
|
// start setInterval to trigger additions per 100 or so to drawn_movements (rendered on map)
|
||||||
|
// when done, trigger parent.done()
|
||||||
|
console.log('s',data.occurences)
|
||||||
|
const [ item, movements ]= this.pickMovements(data.occurences);
|
||||||
|
this.item = item
|
||||||
|
this.movements = movements
|
||||||
|
console.log(item, movements)
|
||||||
|
|
||||||
|
this.motions = viz_data.drawn_motions
|
||||||
|
this.motions.update(items => [])
|
||||||
|
|
||||||
|
this.interval = setInterval(this.tick.bind(this), 3000);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pickMovements(occurences: Map<Item, Movement[]>) {
|
||||||
|
|
||||||
|
const item_movements = [...occurences]
|
||||||
|
// TODO: variable lenght, prefer with most steps
|
||||||
|
.filter(([item, movements]) => movements.length > 1)
|
||||||
|
|
||||||
|
return item_movements[Math.floor(Math.random() * item_movements.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if (this.step >= this.movements.length) {
|
||||||
|
console.log('this', 'done')
|
||||||
|
// todo: ease out all entries
|
||||||
|
this.nextScene()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration 5000 + Math.random() * 10000
|
||||||
|
const motion: Motion = { duration: 2000, movement: this.movements[this.step] };
|
||||||
|
console.log(motion, motion.movement.source, motion.movement.target)
|
||||||
|
this.motions.update(items => ([...items, motion]))
|
||||||
|
this.step += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene_type = 'dedicated' //dedicated (like timeline) or overlay (like oldest)
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue