Compare commits

..

3 commits

Author SHA1 Message Date
Ruben van de Ven
c8187f3f5e Types to more systematically interface data in scenes 2024-05-26 21:05:42 +02:00
Ruben van de Ven
1789731ab9 Use SCSS as preprocessor and WIP 'scenes' setup 2024-05-26 20:42:15 +02:00
Ruben van de Ven
070c3ef848 Width & Height are props, and movement-paths animate in 2024-05-23 20:19:21 +02:00
6 changed files with 527 additions and 113 deletions

41
package-lock.json generated
View file

@ -9,9 +9,12 @@
"version": "0.0.0",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@sveu/browser": "^1.0.1",
"@tsconfig/svelte": "^5.0.2",
"sass": "^1.77.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.7",
"svelte-preprocess": "^5.1.4",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^5.2.0"
@ -728,6 +731,21 @@
"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": {
"version": "5.0.4",
"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==",
"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": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -1546,6 +1570,23 @@
"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": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz",

View file

@ -11,9 +11,12 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@sveu/browser": "^1.0.1",
"@tsconfig/svelte": "^5.0.2",
"sass": "^1.77.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.7",
"svelte-preprocess": "^5.1.4",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^5.2.0"

View file

@ -1,62 +1,110 @@
<script lang="ts">
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);
// TODO: pass these from main.ts (or vice versaas)
const width = 2160;
const height = 3840;
// preprocess data
const nodes = parsed_requests.nodes;
const edges: Array<Object> = parsed_requests.edges;
const _nodes: Location[] = parsed_requests.nodes;
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.
// then map these coordinates to the canvas space
const valid_nodes = nodes
const locations = new Map(_nodes
.filter((n) => n.lat && n.lon)
.map((node) => {
// const { x: x1, y: y1 } = latLonToOffsets(node.lat, node.lon, 100000, 100000)
const { x: x1, y: y1 } = latLonToOffsets(
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;
node["x"] = node_positions[node.code][0];
node["y"] = node_positions[node.code][1];
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(
(l) =>
nodeMap[l["Owning Library Name"]] &&
nodeMap[l["Pickup Location"]],
locations.has(l["Owning Library Name"]) &&
locations.has(l["Pickup Location"]),
)
.map((l, idx) => {
l.source = nodeMap[l["Owning Library Name"]];
l.target = nodeMap[l["Pickup Location"]];
l.nr = idx;
return l;
.forEach((r, idx) => {
const identifier: String = r["Barcode"];
if (!items.has(identifier)) {
items.set(identifier, {
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;
}
@ -76,7 +124,7 @@
return { x, y };
}
function lerp(X, Y, t) {
function lerp(X: number, Y: number, t: number) {
return X * t + Y * (1 - t);
}
@ -88,104 +136,174 @@
[index: string]: Array<number>;
}
function buildIndex(objectList: Array<Object>, field: string): IndexObject {
const index = {};
for (const idx in objectList) {
const v = objectList[idx][field];
if (!index.hasOwnProperty(v)) {
index[v] = [idx];
} else {
index[v].push(idx);
}
}
return index;
// function buildIndex(objectList: Array<Object>, field: string): IndexObject {
// const index = {};
// for (const idx in objectList) {
// const v = objectList[idx][field];
// if (!index.hasOwnProperty(v)) {
// index[v] = [idx];
// } else {
// index[v].push(idx);
// }
// }
// 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) {
const m = movement;
let sourceX, targetX, midX, dx, dy, angle;
let drawn_motions = writable(<Motion[]>[]); //.filter((m, i) => i < 100);
$: opacity = Math.max(0.055, Math.min(1, 200 / $drawn_motions.length));
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.
// 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;
const viz_data: VizData = {
drawn_motions: drawn_motions,
overlay_motions: overlay_motions,
events: events,
}
dx = targetX - sourceX;
dy = m.target.y - m.source.y;
angle = Math.atan2(dx, dy);
import { onMount, onDestroy } from "svelte";
import { writable } from "svelte/store";
var srcSize = 5; //_mapGraph.getSizeForNode(m.source);
var tgtSize = 5; //_mapGraph.getSizeForNode(m.target);
let scenes = [All, Timeline];
let currentSceneI = 0;
let currentScene: Scene;
// 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 angle to compensate roughly for the curve
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}`;
function nextScene() {
if (currentScene) {
currentScene.stop();
}
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>
<h1>library of <span id="motiontitle">motions</span></h1>
<div id="about">
Work by <em>Ruben van de Ven</em> for the
<em>University of Amsterdam Library</em>. Fonts by
<em>Open Source Publishing</em>, map by <em>OpenStreetMap</em>.
<span>Work by <em>Ruben van de Ven</em> for the
<em>University of Amsterdam Library</em>.</span>
<span>Fonts by <em>Open Source Publishing</em>.</span>
</div>
<svg {width} {height}>
<g id="movements">
{#each drawn_movements as m}
<path d={get_path_d(m)}></path>
{#each $drawn_motions as m}
<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}
</g>
<g id="libraries">
{#each valid_nodes as node}
{#each locations.values() as node}
<g id={node.code} transform="translate({node.x}, {node.y})">
<circle r="5"></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>
{/each}
</g>
</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 {
position: absolute;
font-size: 8pt;
@ -193,6 +311,10 @@
right: 20px;
width: 450px;
text-align: right;
span{
display:block;
}
}
#libraries circle:first-child {
fill: white;
@ -204,10 +326,15 @@
stroke-width: 10;
}
path {
stroke: #bc89ff10;
stroke: #bc89ff;
stroke-width: 2px;
fill: none;
}
#overlay_motions path {
stroke: red;
stroke-width: 4;
opacity: 1;
}
text {
fill: white;
font-size: 30pt;

107
src/lib/types.ts Normal file
View 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}`;
}

View file

@ -7,7 +7,15 @@ document.getElementById('canvas')!.style.width = `${width}px`;
document.getElementById('canvas')!.style.height = `${height}px`;
const app = new Viz({
// see https://svelte.dev/docs/client-side-component-api
target: document.getElementById('viz')!,
props: {
width: width,
height: height,
dev: true
},
// intro: true,
})
export default app

128
src/scenes/Scenes.ts Normal file
View 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)
}