Use SCSS as preprocessor and WIP 'scenes' setup

This commit is contained in:
Ruben van de Ven 2024-05-26 20:42:15 +02:00
parent 070c3ef848
commit 1789731ab9
6 changed files with 452 additions and 131 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,11 +1,23 @@
<script lang="ts">
import parsed_requests from "/data/parsed_requests.json";
import { draw, slide } from "svelte/transition";
import { fps } from "@sveu/browser";
import { draw } from "svelte/transition";
import { All, Scene, Timeline } from "./scenes/Scenes";
import {
type Location,
type Movement,
type Item,
type Motion,
get_path_d,
} 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],
@ -26,65 +38,69 @@
};
const node_positions = Object.fromEntries(
Object.entries(node_positions_percentages).map(([k, p]) => [
k,
[(p[0] / 100) * width, (p[1] / 100) * height],
]),
);
Object.entries(node_positions_percentages).map(([k, p]) => [
k,
[(p[0] / 100) * width, (p[1] / 100) * height],
]),
);
$: console.log(parsed_requests);
// 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 = _nodes
.filter((n) => n.lat && n.lon)
.map((node) => {
node["x"] = node_positions[node.code][0];
node["y"] = node_positions[node.code][1];
// 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;
return node;
});
// create an index to access the node objects by their name
const nodeMap: Map<String, Location> = new Map(
locations.map((d) => [d["name"], d]),
);
const nodeMap = Object.fromEntries(valid_nodes.map((d) => [d["name"], d]));
const edgeIndexBarcode = buildIndex(edges, "Barcode");
// console.log(edgeIndexBarcode);
const movements = edges
_requests
.filter((n) => n['Owning Library Name'] != n['Pickup Location'])
.filter(
(l) =>
nodeMap[l["Owning Library Name"]] &&
nodeMap[l["Pickup Location"]],
nodeMap.has(l["Owning Library Name"]) &&
nodeMap.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: nodeMap.get(r["Owning Library Name"]),
target: nodeMap.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);
// const edgeIndexBarcode = buildIndex(edges, "Barcode");
// console.log(edgeIndexBarcode);
function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180;
@ -118,118 +134,107 @@
[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;
// }
function get_path_d(movement) {
const m = movement;
let sourceX, targetX, midX, dx, dy, angle;
const occurences = new Map<Item, Movement[]>();
movements.forEach((movement, idx) => {
let movements = occurences.get(movement.item) ?? []
movements.push(movement)
occurences.set(movement.item, movements);
});
// 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 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.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}`;
}
console.log(movements);
let drawn_movements = movements; //.filter((m, i) => i < 100);
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(<Event[]>[]); //.filter((m, i) => i < 100);
import { onMount, onDestroy } from "svelte";
import { writable } from "svelte/store";
let scenes = [All, Timeline];
let currentSceneI = 0;
let currentScene: Scene;
function nextScene() {
if (currentScene) {
currentScene.stop();
}
console.log("Next scene", currentSceneI);
currentScene = new scenes[currentSceneI](
movements,
drawn_motions,
nextScene,
occurences,
);
currentSceneI = (currentSceneI + 1) % scenes.length;
}
let paused = false;
onMount(() => {
let interval = null;
interval = setInterval(() => {
if (paused) {
return;
}
// TODO: change scene
// timeElapsed += 1;
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();
// }
}, 10000);
// // if (remainingTime === 0) {
// // clearInterval(interval);
// // deleteTimer();
// // }
// }, 2000);
return () => {
clearInterval(interval);
}
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}
{#each $drawn_motions as m}
<path
in:draw|global={{ duration: 5000 + Math.random() * 10000 }}
d={get_path_d(m)}
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 as node}
<g id={node.code} transform="translate({node.x}, {node.y})">
<circle r="5"></circle>
<circle r="20"></circle>
@ -239,7 +244,52 @@
</g>
</svg>
<style>
{#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 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;
@ -247,6 +297,10 @@
right: 20px;
width: 450px;
text-align: right;
span{
display:block;
}
}
#libraries circle:first-child {
fill: white;
@ -258,10 +312,15 @@
stroke-width: 10;
}
path {
stroke: #bc89ff15;
stroke: #bc89ff;
stroke-width: 2px;
fill: none;
}
#overlay_motions path {
stroke: red;
stroke-width: 4;
opacity: 1;
}
text {
fill: white;
font-size: 30pt;

89
src/lib/types.ts Normal file
View file

@ -0,0 +1,89 @@
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
};
// An event to trigger drawing an Edge
export type Motion = {
duration: number,
movement: Movement,
}
export type Event = {
date: Date,
title: String,
description: String
}
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

@ -11,7 +11,8 @@ const app = new Viz({
target: document.getElementById('viz')!,
props: {
width: width,
height: height
height: height,
dev: true
},
// intro: true,
})

128
src/scenes/Scenes.ts Normal file
View file

@ -0,0 +1,128 @@
import type { Writable } from "svelte/store";
import type { Item, Motion, Movement } 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(movements: Movement[], motions: Writable<Motion[]>, nextScene: CallableFunction, occurences: Map<Item, Movements[]>) {
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 = movements;
this.motions = 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(allMovements: Movement[], motions: Writable<Motion[]>, nextScene: CallableFunction, occurences: Map<Item, Movements[]>) {
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',occurences)
const [ item, movements ]= this.pickMovements(occurences);
this.item = item
this.movements = movements
console.log(item, movements)
this.motions = 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)
}