initial commit - egui/eframe rewrite

This commit is contained in:
Tomáš Mládek 2026-01-25 01:37:10 +01:00
parent 2a473e6b67
commit 3ce0fce53a
38 changed files with 5155 additions and 5150 deletions

View file

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

1
.env
View file

@ -1 +0,0 @@

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
**/*.ico filter=lfs diff=lfs merge=lfs -text
**/*.png filter=lfs diff=lfs merge=lfs -text

28
.gitignore vendored
View file

@ -1,25 +1,9 @@
.DS_Store
node_modules
/dist /dist
# Rust compile target directories:
target
target_ra
target_wasm
# local env files # https://github.com/lycheeverse/lychee
.env.local .lycheecache
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
public/content

View file

@ -1,31 +0,0 @@
stages:
- build
- deploy
build site:
image: node:lts
stage: build
script:
- npm ci --cache .npm --prefer-offline
- npm run build
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .npm
artifacts:
paths:
- dist
deploy site:
image: instrumentisto/rsync-ssh
stage: deploy
only:
refs:
- master
script:
- mkdir ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- rsync -vr -e "ssh -p ${SSH_PORT}" dist/ "${DEPLOY_DEST}"

3456
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

242
Cargo.toml Normal file
View file

@ -0,0 +1,242 @@
[package]
name = "las"
version = "0.1.0"
authors = ["Tomáš Mládek <t@mldk.cz>"]
edition = "2024"
include = ["LICENSE-APACHE", "LICENSE-MIT", "**/*.rs", "Cargo.toml"]
rust-version = "1.85"
[package.metadata.docs.rs]
all-features = true
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
[dependencies]
egui = "0.32"
eframe = { version = "0.32", default-features = false, features = [
# "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies.
"default_fonts", # Embed the default egui fonts.
"glow", # Use the glow rendering backend. Alternative: "wgpu".
"persistence", # Enable restoring app state when restarting the app.
"wayland", # To support Linux (and CI)
"x11", # To support older Linux distributions (restores one of the default features)
] }
log = "0.4.27"
# You only need serde if you want app persistence:
serde = { version = "1.0.219", features = ["derive"] }
egui_extras = {version="0.32.0", features=["svg"]}
quick-xml = "0.36"
# native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.8"
# web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4.50"
web-sys = "0.3.70" # to access the DOM (to hide the loading text)
[profile.release]
opt-level = 2 # fast and small wasm
# Optimize all dependencies even in debug builds:
[profile.dev.package."*"]
opt-level = 2
[patch.crates-io]
# If you want to use the bleeding edge version of egui and eframe:
# egui = { git = "https://github.com/emilk/egui", branch = "main" }
# eframe = { git = "https://github.com/emilk/egui", branch = "main" }
# If you fork https://github.com/emilk/egui you can test with:
# egui = { path = "../egui/crates/egui" }
# eframe = { path = "../egui/crates/eframe" }
# ----------------------------------------------------------------------------------------
# Lints:
[lints]
workspace = true
[workspace.lints.rust]
unsafe_code = "deny"
elided_lifetimes_in_paths = "warn"
future_incompatible = { level = "warn", priority = -1 }
nonstandard_style = { level = "warn", priority = -1 }
rust_2018_idioms = { level = "warn", priority = -1 }
rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn"
trivial_numeric_casts = "warn"
unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668
unused_extern_crates = "warn"
unused_import_braces = "warn"
unused_lifetimes = "warn"
trivial_casts = "allow"
unused_qualifications = "allow"
[workspace.lints.rustdoc]
all = "warn"
missing_crate_level_docs = "warn"
[workspace.lints.clippy]
allow_attributes = "warn"
as_ptr_cast_mut = "warn"
await_holding_lock = "warn"
bool_to_int_with_if = "warn"
char_lit_as_u8 = "warn"
checked_conversions = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
dbg_macro = "warn"
debug_assert_with_mut_call = "warn"
derive_partial_eq_without_eq = "warn"
disallowed_macros = "warn" # See clippy.toml
disallowed_methods = "warn" # See clippy.toml
disallowed_names = "warn" # See clippy.toml
disallowed_script_idents = "warn" # See clippy.toml
disallowed_types = "warn" # See clippy.toml
doc_include_without_cfg = "warn"
doc_link_with_quotes = "warn"
doc_markdown = "warn"
empty_enum = "warn"
empty_enum_variants_with_brackets = "warn"
enum_glob_use = "warn"
equatable_if_let = "warn"
exit = "warn"
expl_impl_clone_on_copy = "warn"
explicit_deref_methods = "warn"
explicit_into_iter_loop = "warn"
explicit_iter_loop = "warn"
fallible_impl_from = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
float_cmp_const = "warn"
fn_params_excessive_bools = "warn"
fn_to_numeric_cast_any = "warn"
from_iter_instead_of_collect = "warn"
get_unwrap = "warn"
implicit_clone = "warn"
imprecise_flops = "warn"
index_refutable_slice = "warn"
inefficient_to_string = "warn"
infinite_loop = "warn"
into_iter_without_iter = "warn"
invalid_upcast_comparisons = "warn"
iter_filter_is_ok = "warn"
iter_filter_is_some = "warn"
iter_not_returning_iterator = "warn"
iter_on_empty_collections = "warn"
iter_on_single_items = "warn"
iter_over_hash_type = "warn"
iter_without_into_iter = "warn"
large_digit_groups = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
large_stack_frames = "warn"
large_types_passed_by_value = "warn"
let_underscore_must_use = "warn"
let_underscore_untyped = "warn"
let_unit_value = "warn"
linkedlist = "warn"
literal_string_with_formatting_args = "warn"
lossy_float_literal = "warn"
macro_use_imports = "warn"
manual_assert = "warn"
manual_clamp = "warn"
manual_instant_elapsed = "warn"
manual_is_power_of_two = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
manual_ok_or = "warn"
manual_string_new = "warn"
map_err_ignore = "warn"
map_flatten = "warn"
match_bool = "warn"
match_on_vec_items = "warn"
match_same_arms = "warn"
match_wild_err_arm = "warn"
match_wildcard_for_single_variants = "warn"
mem_forget = "warn"
mismatching_type_param_order = "warn"
missing_assert_message = "warn"
missing_enforced_import_renames = "warn"
missing_safety_doc = "warn"
mixed_attributes_style = "warn"
mut_mut = "warn"
mutex_integer = "warn"
needless_borrow = "warn"
needless_continue = "warn"
needless_for_each = "warn"
needless_pass_by_ref_mut = "warn"
needless_pass_by_value = "warn"
negative_feature_names = "warn"
non_zero_suggestions = "warn"
nonstandard_macro_braces = "warn"
option_as_ref_cloned = "warn"
option_option = "warn"
path_buf_push_overwrite = "warn"
pathbuf_init_then_push = "warn"
ptr_as_ptr = "warn"
ptr_cast_constness = "warn"
pub_underscore_fields = "warn"
pub_without_shorthand = "warn"
rc_mutex = "warn"
readonly_write_lock = "warn"
redundant_type_annotations = "warn"
ref_as_ptr = "warn"
ref_option_ref = "warn"
rest_pat_in_fully_bound_structs = "warn"
same_functions_in_if_condition = "warn"
semicolon_if_nothing_returned = "warn"
set_contains_or_insert = "warn"
should_panic_without_expect = "warn"
single_char_pattern = "warn"
single_match_else = "warn"
str_split_at_newline = "warn"
str_to_string = "warn"
string_add = "warn"
string_add_assign = "warn"
string_lit_as_bytes = "warn"
string_lit_chars_any = "warn"
string_to_string = "warn"
suspicious_command_arg_space = "warn"
suspicious_xor_used_as_pow = "warn"
todo = "warn"
too_long_first_doc_paragraph = "warn"
too_many_lines = "warn"
trailing_empty_array = "warn"
trait_duplication_in_bounds = "warn"
tuple_array_conversions = "warn"
unchecked_duration_subtraction = "warn"
undocumented_unsafe_blocks = "warn"
unimplemented = "warn"
uninhabited_references = "warn"
uninlined_format_args = "warn"
unnecessary_box_returns = "warn"
unnecessary_literal_bound = "warn"
unnecessary_safety_doc = "warn"
unnecessary_struct_initialization = "warn"
unnecessary_wraps = "warn"
unnested_or_patterns = "warn"
unused_peekable = "warn"
unused_rounding = "warn"
unused_self = "warn"
unused_trait_names = "warn"
unwrap_used = "warn"
use_self = "warn"
useless_transmute = "warn"
verbose_file_reads = "warn"
wildcard_dependencies = "warn"
wildcard_imports = "warn"
zero_sized_map_values = "warn"
manual_range_contains = "allow" # this is better on 'allow'
map_unwrap_or = "allow" # this is better on 'allow'

2
Trunk.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
filehash = false

270
asset.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.7 MiB

3
assets/favicon.ico Executable file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c61457f43af60ad7cf5fb1420d2e6f484f85e1185c38f351e57531a51ca1a5e
size 15406

3
assets/icon-1024.png Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:87c993335fd0b0bf16a276627d205ebd703c2a9a762035973cef8032cd4df6e3
size 321266

3
assets/icon-256.png Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b33a7d4fdbf76834bcf39551a63b87e7b32cf6d1ccb2d0d3cbcf88bf2ae4828a
size 48330

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c6ab5ae62953d1093ac273d6cc39a9c9015986bee1aed1f7cf013a0e6e3fe030
size 21131

28
assets/manifest.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "egui Template PWA",
"short_name": "egui-template-pwa",
"icons": [
{
"src": "./assets/icon-256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "./assets/maskable_icon_x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./assets/icon-1024.png",
"sizes": "1024x1024",
"type": "image/png"
}
],
"lang": "en-US",
"id": "/index.html",
"start_url": "./index.html",
"display": "standalone",
"background_color": "white",
"theme_color": "white"
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e1a2353165288b7762da7c4fc4511ad409aff46bc2fa05f447bb0cf428d1917c
size 130625

25
assets/sw.js Normal file
View file

@ -0,0 +1,25 @@
var cacheName = 'egui-template-pwa';
var filesToCache = [
'./',
'./index.html',
'./las.js',
'./las_bg.wasm',
];
/* Start the service worker and cache all of the app's content */
self.addEventListener('install', function (e) {
e.waitUntil(
caches.open(cacheName).then(function (cache) {
return cache.addAll(filesToCache);
})
);
});
/* Serve cached content when offline */
self.addEventListener('fetch', function (e) {
e.respondWith(
caches.match(e.request).then(function (response) {
return response || fetch(e.request);
})
);
});

View file

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

3894
bun.lock

File diff suppressed because it is too large Load diff

11
check.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# This scripts runs various CI-like checks in a convenient way.
set -eux
cargo check --quiet --workspace --all-targets
cargo check --quiet --workspace --all-features --lib --target wasm32-unknown-unknown
cargo fmt --all -- --check
cargo clippy --quiet --workspace --all-targets --all-features -- -D warnings -W clippy::all
cargo test --quiet --workspace --all-targets --all-features
cargo test --quiet --workspace --doc
trunk build

148
index.html Normal file
View file

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- Disable zooming: -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<head>
<!-- change this to your project name -->
<title>las-rs</title>
<!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
<link data-trunk rel="rust" data-wasm-opt="2" />
<!-- this is the base url relative to which other urls will be constructed. trunk will insert this from the public-url option -->
<base data-trunk-public-url />
<link data-trunk rel="icon" href="assets/favicon.ico">
<link data-trunk rel="copy-file" href="assets/sw.js"/>
<link data-trunk rel="copy-file" href="assets/manifest.json"/>
<link data-trunk rel="copy-file" href="assets/icon-1024.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/icon-256.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/icon_ios_touch_192.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/maskable_icon_x512.png" data-target-path="assets"/>
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="assets/icon_ios_touch_192.png">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#404040">
<style>
html {
/* Remove touch delay: */
touch-action: manipulation;
}
body {
/* Light mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #909090;
}
@media (prefers-color-scheme: dark) {
body {
/* Dark mode background color for what is not covered by the egui canvas,
or where the egui canvas is translucent. */
background: #404040;
}
}
/* Allow canvas to fill entire web page: */
html,
body {
overflow: hidden;
margin: 0 !important;
padding: 0 !important;
height: 100%;
width: 100%;
}
/* Make canvas fill entire document: */
canvas {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.centered {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #f0f0f0;
font-size: 24px;
font-family: Ubuntu-Light, Helvetica, sans-serif;
text-align: center;
}
/* ---------------------------------------------- */
/* Loading animation from https://loading.io/css/ */
.lds-dual-ring {
display: inline-block;
width: 24px;
height: 24px;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 24px;
height: 24px;
margin: 0px;
border-radius: 50%;
border: 3px solid #fff;
border-color: #fff transparent #fff transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<!-- The WASM code will resize the canvas dynamically -->
<!-- the id is hardcoded in main.rs . so, make sure both match. -->
<canvas id="the_canvas_id"></canvas>
<!-- the loading spinner will be removed in main.rs -->
<div class="centered" id="loading_text">
<p style="font-size:16px">
Loading…
</p>
<div class="lds-dual-ring"></div>
</div>
<!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
<!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files -->
<script>
// We disable caching during development so that we always view the latest version.
if ('serviceWorker' in navigator && window.location.hash !== "#dev") {
window.addEventListener('load', function () {
navigator.serviceWorker.register('sw.js');
});
}
</script>
</body>
</html>
<!-- Powered by egui: https://github.com/emilk/egui/ -->

View file

@ -1,32 +0,0 @@
{
"name": "line-and-surface",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve",
"build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build"
},
"dependencies": {
"@vue/cli": "^4.5.12",
"core-js": "^3.6.5",
"fetch-progress": "^1.3.0",
"normalize.css": "^8.0.1",
"panzoom": "^9.4.1",
"stats.js": "^0.17.0",
"vue": "^3.0.0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@types/stats.js": "^0.17.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@xmldom/xmldom": "^0.9.8",
"commander": "^14.0.0",
"tsx": "^4.20.3",
"typescript": "~3.9.3"
}
}

View file

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
<title>Line and Surface</title>
</head>
<body>
<noscript>
<strong>We're sorry but Line and Surface doesn't work properly without JavaScript
enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script data-goatcounter="https://las.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>

10
rust-toolchain Normal file
View file

@ -0,0 +1,10 @@
# If you see this, run "rustup self update" to get rustup 1.23 or newer.
# NOTE: above comment is for older `rustup` (before TOML support was added),
# which will treat the first line as the toolchain name, and therefore show it
# to the user in the error, instead of "error: invalid channel name '[toolchain]'".
[toolchain]
channel = "1.85" # Avoid specifying a patch version here; see https://github.com/emilk/eframe_template/issues/145
components = [ "rustfmt", "clippy" ]
targets = [ "wasm32-unknown-unknown" ]

View file

@ -1,42 +0,0 @@
<template>
<SVGContent
id="root"
url="content/intro.svg"
@set-background="setBackground"
/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import SVGContent from "@/components/SVGContent.vue";
import "normalize.css";
export default defineComponent({
name: "App",
components: {
SVGContent,
},
methods: {
setBackground(background: string) {
document.body.style.background = background;
},
},
});
</script>
<style>
html,
body {
overflow: hidden;
background: black;
}
html,
body,
#app,
#root {
width: 100%;
height: 100%;
cursor: default;
}
</style>

376
src/app.rs Normal file
View file

@ -0,0 +1,376 @@
use crate::svg::{Renderable as _, SvgContent};
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct TemplateApp {
#[serde(skip)]
svg_content: Option<SvgContent>,
// Pan offset in SVG coordinates
pan_x: f32,
pan_y: f32,
// Zoom factor (1.0 = 100%)
zoom: f32,
#[serde(skip)]
show_menu_bar: bool,
#[serde(skip)]
show_debug: bool,
/// Exponential moving average of frame time for stable FPS display
#[serde(skip)]
fps_ema: f32,
/// Last pointer position for manual drag tracking (smoother than Sense::drag)
#[serde(skip)]
last_pointer_pos: Option<egui::Pos2>,
/// Whether we're currently in a drag operation
#[serde(skip)]
is_dragging: bool,
}
impl Default for TemplateApp {
fn default() -> Self {
Self {
svg_content: None,
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
show_menu_bar: false,
show_debug: false,
fps_ema: 60.0, // Start with reasonable default
last_pointer_pos: None,
is_dragging: false,
}
}
}
impl TemplateApp {
/// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// This is also where you can customize the look and feel of egui using
// `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
// Ensure image loaders (including SVG) are available on both native and web:
egui_extras::install_image_loaders(&cc.egui_ctx);
// Load previous app state (if any).
let mut app: Self = if let Some(storage) = cc.storage {
eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
} else {
Default::default()
};
// Load SVG content
let svg_path = "../line-and-surface/content/intro.svg";
match SvgContent::from_file(svg_path) {
Ok(content) => {
log::info!(
"Loaded SVG: {} video scrolls, {} audio areas, {} anchors, {} texts",
content.video_scrolls.len(),
content.audio_areas.len(),
content.anchors.len(),
content.texts.len()
);
if let Some((min_x, min_y, _, _)) = content.viewbox {
// Initialize pan to center on content
app.pan_x = -min_x;
app.pan_y = -min_y;
}
app.svg_content = Some(content);
}
Err(e) => {
log::error!("Failed to load SVG: {e}");
}
}
app
}
/// Convert from SVG coordinates to screen coordinates.
fn svg_to_screen(&self, x: f32, y: f32, canvas_rect: &egui::Rect) -> egui::Pos2 {
let screen_x = (x + self.pan_x) * self.zoom + canvas_rect.left();
let screen_y = (y + self.pan_y) * self.zoom + canvas_rect.top();
egui::pos2(screen_x, screen_y)
}
}
impl eframe::App for TemplateApp {
/// Called by the framework to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
/// Called each time the UI needs repainting, which may be many times per second.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Batch input reading for efficiency
// Use smooth_scroll_delta for smoother panning experience
let (
escape_pressed,
f3_pressed,
scroll_delta,
zoom_delta,
pointer_pos,
frame_time,
primary_down,
primary_pressed,
primary_released,
) = ctx.input(|i| {
(
i.key_pressed(egui::Key::Escape),
i.key_pressed(egui::Key::F3),
i.smooth_scroll_delta.y, // Use smoothed scroll instead of raw
i.zoom_delta(),
i.pointer.hover_pos(),
i.stable_dt, // Actual frame time for FPS calculation
i.pointer.primary_down(),
i.pointer.primary_pressed(),
i.pointer.primary_released(),
)
});
// Update FPS using exponential moving average for stable display
// Alpha of 0.1 means ~10 frames to reach 63% of a new stable value
// This provides good smoothing while still being responsive
if frame_time > 0.0 {
let current_fps = 1.0 / frame_time;
const ALPHA: f32 = 0.1;
self.fps_ema = ALPHA * current_fps + (1.0 - ALPHA) * self.fps_ema;
}
if escape_pressed {
self.show_menu_bar = !self.show_menu_bar;
}
if f3_pressed {
self.show_debug = !self.show_debug;
}
if self.show_menu_bar {
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
let is_web = cfg!(target_arch = "wasm32");
if !is_web {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.add_space(16.0);
}
egui::widgets::global_theme_preference_buttons(ui);
ui.separator();
// Display current zoom level
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
if ui.button("Reset View").clicked() {
self.pan_x = 0.0;
self.pan_y = 0.0;
self.zoom = 1.0;
}
ui.separator();
if ui
.button(if self.show_debug {
"Hide Debug (F3)"
} else {
"Show Debug (F3)"
})
.clicked()
{
self.show_debug = !self.show_debug;
}
});
});
}
// Track rendered element count for debug
let mut rendered_count = 0u32;
egui::CentralPanel::default().show(ctx, |ui| {
// Allocate the full panel - use click_and_drag to capture hover and clicks
let (response, painter) =
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
let canvas_rect = response.rect;
// Manual drag handling for smoother panning
if primary_pressed && response.hovered() {
self.is_dragging = true;
self.last_pointer_pos = pointer_pos;
}
if primary_released {
self.is_dragging = false;
self.last_pointer_pos = None;
}
// Calculate delta from pointer movement
if self.is_dragging && primary_down {
if let (Some(current_pos), Some(last_pos)) = (pointer_pos, self.last_pointer_pos) {
let delta = current_pos - last_pos;
if delta.x != 0.0 || delta.y != 0.0 {
self.pan_x += delta.x / self.zoom;
self.pan_y += delta.y / self.zoom;
}
}
self.last_pointer_pos = pointer_pos;
}
// Handle scroll wheel (zoom) - only if hovered
if response.hovered() {
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
let new_zoom = (self.zoom * zoom_factor).clamp(0.01, 100.0);
// Zoom towards pointer position
if let Some(pos) = pointer_pos {
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
}
self.zoom = new_zoom;
}
// Also support trackpad pinch zoom
if zoom_delta != 1.0 {
let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0);
if let Some(pos) = pointer_pos {
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
}
self.zoom = new_zoom;
}
}
// Define colors for each element type
let video_scroll_color = egui::Color32::from_rgb(70, 130, 180); // Steel Blue
let audio_area_color = egui::Color32::from_rgb(60, 179, 113); // Medium Sea Green
let anchor_color = egui::Color32::from_rgb(255, 215, 0); // Gold
let text_color = egui::Color32::from_rgb(147, 112, 219); // Medium Purple
// Render SVG content with frustum culling
if let Some(ref content) = self.svg_content {
// Draw video scrolls
for vs in &content.video_scrolls {
let (x, y, w, h) = vs.bounds();
let min = self.svg_to_screen(x, y, &canvas_rect);
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
let rect = egui::Rect::from_min_max(min, max);
// Frustum culling: skip if completely outside canvas
if !rect.intersects(canvas_rect) {
continue;
}
painter.rect_filled(rect, 0.0, video_scroll_color);
rendered_count += 1;
}
// Draw audio areas
for aa in &content.audio_areas {
let (x, y, w, h) = aa.bounds();
let min = self.svg_to_screen(x, y, &canvas_rect);
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
let rect = egui::Rect::from_min_max(min, max);
if !rect.intersects(canvas_rect) {
continue;
}
painter.rect_filled(rect, (w.min(h) * self.zoom) / 2.0, audio_area_color);
rendered_count += 1;
}
// Draw anchors
for anchor in &content.anchors {
let (x, y, w, h) = anchor.bounds();
let min = self.svg_to_screen(x, y, &canvas_rect);
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
let rect = egui::Rect::from_min_max(min, max);
if !rect.intersects(canvas_rect) {
continue;
}
painter.rect_filled(rect, 0.0, anchor_color);
rendered_count += 1;
}
// Draw text elements
for text in &content.texts {
let (x, y, w, h) = text.bounds();
let min = self.svg_to_screen(x, y, &canvas_rect);
let max = self.svg_to_screen(x + w, y + h, &canvas_rect);
let rect = egui::Rect::from_min_max(min, max);
if !rect.intersects(canvas_rect) {
continue;
}
painter.rect_filled(rect, 0.0, text_color);
rendered_count += 1;
}
// Draw a subtle border around the viewbox if available
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
let min = self.svg_to_screen(vb_x, vb_y, &canvas_rect);
let max = self.svg_to_screen(vb_x + vb_w, vb_y + vb_h, &canvas_rect);
painter.rect_stroke(
egui::Rect::from_min_max(min, max),
0.0,
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
egui::StrokeKind::Inside,
);
}
}
});
// Debug overlay window
if self.show_debug {
egui::Window::new("Debug")
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
ui.label(format!("FPS: {:.1}", self.fps_ema));
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
ui.separator();
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
ui.label(format!("Zoom: {:.2}x", self.zoom));
ui.separator();
if let Some(ref content) = self.svg_content {
let total = content.video_scrolls.len()
+ content.audio_areas.len()
+ content.anchors.len()
+ content.texts.len();
ui.label(format!("Total elements: {total}"));
ui.label(format!("Rendered: {rendered_count}"));
ui.label(format!(
"Culled: {}",
total.saturating_sub(rendered_count as usize)
));
}
});
}
}
}

View file

@ -1,108 +0,0 @@
<template>
<audio ref="audio" :src="audioSrc" loop preload="auto" />
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch } from "vue";
import { BoundingBox } from "@/components/SVGContent.vue";
export default defineComponent({
name: "AudioArea",
props: {
definition: {
type: Object as PropType<AudioAreaDef>,
required: true,
},
bbox: {
type: Object as PropType<BoundingBox>,
required: true,
},
},
setup(props) {
const audio = ref<HTMLAudioElement | null>(null);
const audioSrc = ref<string>(""); // Ref to hold audio source after preloading
const isPreloaded = ref<boolean>(false);
console.debug(`[AUDIOAREA] Initializing ${props.definition.src}...`);
console.debug(props.definition);
// Preload the audio file completely to avoid keeping connections open
const preloadAudio = async (src: string) => {
console.debug(`[AUDIOAREA] Preloading audio: ${src}`);
try {
// Fetch the entire audio file
const response = await fetch(src);
if (!response.ok) throw new Error(`Failed to load audio: ${response.statusText}`);
// Convert to blob to ensure full download
const blob = await response.blob();
// Create a blob URL to use as the audio source
const blobUrl = URL.createObjectURL(blob);
audioSrc.value = blobUrl;
isPreloaded.value = true;
console.debug(`[AUDIOAREA] Successfully preloaded audio: ${src}`);
} catch (error) {
console.error(`[AUDIOAREA] Error preloading audio: ${error}`);
// Fall back to original source if preloading fails
audioSrc.value = src;
}
};
// Start preloading when component is created
preloadAudio(props.definition.src);
const MIN_SCALE = 0.02;
const MIN_VOLUME_MULTIPLIER = 0.33;
const vol_x = (1 - MIN_VOLUME_MULTIPLIER) / (1 - MIN_SCALE);
const vol_b = 1 - vol_x;
const onBBoxChange = () => {
const x = props.bbox.x + props.bbox.w / 2;
const y = props.bbox.y + props.bbox.h / 2;
const distance = Math.sqrt(
Math.pow(x - props.definition.cx, 2) +
Math.pow(y - props.definition.cy, 2)
);
if (distance < props.definition.radius) {
if (audio.value!.paused) {
console.debug(
`[AUDIOAREA] Entered audio area "${props.definition.src}", starting playback...`
);
audio.value!.play();
}
const volume =
(props.definition.radius - distance) / props.definition.radius;
audio.value!.volume =
volume * (props.bbox.z < 1 ? props.bbox.z * vol_x + vol_b : 1);
} else {
if (!audio.value!.paused) {
console.debug(
`[AUDIOAREA] Left audio area "${props.definition.src}", pausing playback...`
);
audio.value!.pause();
}
}
};
watch(props.bbox, onBBoxChange, { deep: true });
return {
audio,
audioSrc,
};
},
});
export interface AudioAreaDef {
id: string;
cx: number;
cy: number;
radius: number;
src: string;
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View file

@ -1,656 +0,0 @@
<template>
<div class="svg-content">
<div :class="['loading-screen', { loaded: loadedPercent === 100 }]">
<div :style="{ width: `${loadedPercent}%` }" class="loading-bar"></div>
</div>
<div class="content" ref="root">
<div class="video-scrolls">
<VideoScroll
v-for="scroll in scrolls"
:definition="scroll"
:key="scroll.id"
/>
</div>
</div>
<AudioArea
v-for="audio in audioAreas"
:definition="audio"
:bbox="bbox"
:key="audio.id"
/>
<div class="dev devpanel">
<div>
<span>Current viewport position:</span>
<span>{{ Math.round(bbox.x) }}x{{ Math.round(bbox.y) }}</span>
</div>
<div>
<span>Current cursor position:</span>
<span
>{{ Math.round(mousePosition.x) }}x{{
Math.round(mousePosition.y)
}}</span
>
</div>
<div>
<span>Zoom level:</span
><span>{{ Math.round(bbox.z * 1000) / 1000 }}</span>
</div>
<label>
<input v-model="showInternal" type="checkbox" />
<label>Show internal elements</label>
</label>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, ref } from "vue";
import createPanZoom, { PanZoom } from "panzoom";
import VideoScroll, {
VideoScrollDef,
VideoScrollDirection,
} from "@/components/VideoScroll.vue";
import AudioArea, { AudioAreaDef } from "@/components/AudioArea.vue";
import Stats from "stats.js";
import { rotate } from "@/utils";
import fetchProgress from "fetch-progress";
export interface BoundingBox {
x: number;
y: number;
w: number;
h: number;
z: number;
}
export default defineComponent({
name: "SVGContent",
components: { AudioArea, VideoScroll },
props: {
url: {
type: String,
required: true,
},
},
data() {
return {
showInternal: false,
};
},
watch: {
showInternal(value) {
Array.from(this.root!.getElementsByClassName("internal")).forEach(
(el) => {
(el as SVGElement).style.visibility = value ? "visible" : "hidden";
}
);
},
},
setup(props, { emit }) {
const root = ref<HTMLDivElement | null>(null);
const loadedPercent = ref(0);
const panzoom = ref<null | PanZoom>(null);
const anchors = ref<SVGRectElement[]>([]);
const scrolls = ref<VideoScrollDef[]>([]);
const panToAnchor = ref();
const audioAreas = ref<AudioAreaDef[]>([]);
const bbox: BoundingBox = reactive({
x: ref(0),
y: ref(0),
w: ref(0),
h: ref(0),
z: ref(1),
});
const mousePosition = reactive({
x: ref(0),
y: ref(0),
});
const panning = ref(false);
onMounted(async () => {
const element = (root.value as unknown) as HTMLDivElement;
console.info("[SVG] Initializing.");
// Fetch & load SVG
console.info(`[SVG] Fetching "${props.url}..."`);
const fetchResult = await fetch(props.url).then(
fetchProgress({
onProgress(progress) {
loadedPercent.value = (progress as any).percentage;
},
})
);
const svgParsed = new DOMParser().parseFromString(
await fetchResult.text(),
"image/svg+xml"
) as Document;
console.debug("[SVG] Loaded.");
loadedPercent.value = 100;
const svg = element.appendChild(
svgParsed.firstElementChild as Element
) as any;
// Set document background
const pageColor = svg
.getElementById("base")
?.attributes.getNamedItem("pagecolor");
if (pageColor) {
console.debug(`[SVG] Found pageColor attribute: ${pageColor.value}`);
emit("setBackground", pageColor.value);
}
// PanZoom
const pz = createPanZoom(element, {
smoothScroll: false,
minZoom: 0.05,
maxZoom: 3637937,
zoomSpeed: 0.05,
zoomDoubleClickSpeed: 1,
beforeMouseDown: () => {
return panning.value;
},
beforeWheel: () => {
return panning.value;
},
onDoubleClick: () => {
if (!document.fullscreenElement) {
console.debug("[SVG] Fullscreen requested.");
document.body.requestFullscreen();
} else {
console.debug("[SVG] Fullscreen exited.");
document.exitFullscreen();
}
return true;
},
});
panzoom.value = pz;
// Calculate SVG-unit bounding box, update transform
pz.on("transform", function (_) {
const transform = pz.getTransform();
const currentRatio =
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
bbox.x = (transform.x / currentRatio) * -1;
bbox.y = (transform.y / currentRatio) * -1;
bbox.w = window.innerWidth / currentRatio;
bbox.h = window.innerHeight / currentRatio;
bbox.z = transform.scale;
window.location.hash = `${Math.round(bbox.x + bbox.w / 2)},${Math.round(
bbox.y + bbox.h / 2
)},${Math.round(transform.scale * 1000) / 1000}z`;
});
function panToElement(target: SVGRectElement, smooth: boolean) {
console.debug(`[SVG] Panning to element: #${target.id}`);
const transform = pz.getTransform();
const currentRatio =
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
const ratio = svg.clientWidth / svg.viewBox.baseVal.width;
const targetScale =
window.innerWidth / (target.width.baseVal.value * ratio);
const svgTargetX =
(target.x.baseVal.value + target.width.baseVal.value / 2) *
currentRatio;
const svgTargetY =
(target.y.baseVal.value + target.height.baseVal.value / 2) *
currentRatio;
if (smooth) {
panning.value = true;
pz.smoothMoveTo(
svgTargetX * -1 + window.innerWidth / 2,
svgTargetY * -1 + window.innerHeight / 2
);
setTimeout(() => {
const finalTransform = pz.getTransform();
pz.smoothZoomAbs(
svgTargetX + finalTransform.x,
svgTargetY + finalTransform.y,
targetScale
);
setTimeout(() => {
panning.value = false;
}, 400);
}, 400 * 4);
} else {
pz.moveTo(
svgTargetX * -1 + window.innerWidth / 2,
svgTargetY * -1 + window.innerHeight / 2
);
pz.zoomAbs(
window.innerWidth / 2,
window.innerHeight / 2,
targetScale
);
}
}
panToAnchor.value = (anchor: SVGRectElement) => {
panToElement(anchor, true);
};
// Process start element
const start = processStart(svg);
if (start) {
console.info("[SVG] Found start element.");
window.addEventListener("keydown", (ev) => {
if (ev.key === " ") {
panToElement(start, true);
}
});
}
// Pan to start element or location in hash
const locationMatch = window.location.href.match(
/#([\-0-9.]+),([\-0-9.]+),([0-9.]+)z/
);
if (locationMatch) {
console.debug(`[SVGCONTENT] Got a location match: ${locationMatch}`);
const [_, x, y, z] = locationMatch;
const transform = pz.getTransform();
const currentRatio =
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
pz.moveTo(
parseFloat(x) * currentRatio * -1 + window.innerWidth / 2,
parseFloat(y) * currentRatio * -1 + window.innerHeight / 2
);
pz.zoomAbs(
window.innerWidth / 2,
window.innerHeight / 2,
parseFloat(z)
);
} else if (start) {
console.debug(`[SVGCONTENT] Panning to start anchor.`);
panToElement(start, false);
}
// Anchors
console.debug("[SVG] Processing anchors.");
anchors.value = processAnchors(svg);
console.info(`[SVG] Found ${anchors.value.length} anchors.`);
// Links
console.debug("[SVG] Processing hyperlinks.");
const { anchor, hyper } = processHyperlinks(svg);
console.info(
`[SVG] Found ${anchor.length} anchor links and ${hyper.length} hyperlinks.`
);
anchor.forEach(([anchorId, element]) => {
const anchor = anchors.value.find((a) => a.id == anchorId);
if (!anchor) {
console.error(`[SVG] Could not find anchor #${anchorId}!`);
return;
}
element.addEventListener("click", () => {
panToElement(anchor, true);
});
});
// Audio areas
console.debug("[SVG] Processing audio areas.");
audioAreas.value = processAudio(svg);
console.info(`[SVG] Found ${audioAreas.value.length} audio areas.`);
// Videoscrolls
console.debug("[SVG] Processing video scrolls.");
scrolls.value = await processScrolls(svg);
console.info(`[SVG] Found ${scrolls.value.length} video scrolls.`);
// Debug Stats
let stats: Stats | undefined;
if (window.location.search.includes("debug")) {
console.info("[SVG] DEBUG mode active, turning on stats & dev panel.");
stats = new Stats();
document.body.appendChild(stats.dom);
Array.from(document.body.getElementsByClassName("dev")).forEach(
(el) => {
(el as HTMLElement).style.display = "block";
}
);
}
// Animations: FPS Counter, Edge scrolling
let mouse: MouseEvent | undefined;
window.addEventListener("mousemove", (ev) => {
mouse = ev;
const transform = pz.getTransform();
const currentRatio =
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
mousePosition.x = (mouse.clientX - transform.x) / currentRatio;
mousePosition.y = (mouse.clientY - transform.y) / currentRatio;
});
let gamePadZoomSpeed = 10;
function animate() {
if (stats) {
stats.begin();
}
// Edge scrolling
const MOVE_EDGE_X = window.innerWidth * 0.25;
const MOVE_EDGE_Y = window.innerHeight * 0.25;
const MAX_SPEED = 20;
if (mouse && !panning.value && document.fullscreenElement) {
let horizontalShift: number;
let verticalShift: number;
const transform = pz.getTransform();
if (
mouse.clientX < MOVE_EDGE_X ||
mouse.clientX > window.innerWidth - MOVE_EDGE_X
) {
const horizontalEdgeDistance =
mouse.clientX < window.innerWidth / 2
? mouse.clientX
: mouse.clientX - window.innerWidth;
const horizontalRatio =
(MOVE_EDGE_X - Math.abs(horizontalEdgeDistance)) / MOVE_EDGE_X;
const direction = mouse.clientX < MOVE_EDGE_X ? 1 : -1;
horizontalShift = horizontalRatio * direction * MAX_SPEED;
} else {
horizontalShift = 0;
}
if (
mouse.clientY < MOVE_EDGE_Y ||
mouse.clientY > window.innerHeight - MOVE_EDGE_Y
) {
const verticalEdgeDistance =
mouse.clientY < window.innerHeight / 2
? mouse.clientY
: mouse.clientY - window.innerHeight;
const verticalRatio =
(MOVE_EDGE_Y - Math.abs(verticalEdgeDistance)) / MOVE_EDGE_Y;
const direction = mouse.clientY < MOVE_EDGE_Y ? 1 : -1;
verticalShift = verticalRatio * direction * MAX_SPEED;
} else {
verticalShift = 0;
}
if (horizontalShift || verticalShift) {
pz.moveTo(
transform!.x + horizontalShift,
transform!.y + verticalShift
);
}
}
if (navigator.getGamepads) {
var gamepads = navigator.getGamepads();
var gp = gamepads[0];
if (gp) {
if (gp.buttons[7].pressed) {
gamePadZoomSpeed += 0.1;
}
if (gp.buttons[5].pressed) {
gamePadZoomSpeed -= 0.1;
}
if (gamePadZoomSpeed < 1) {
gamePadZoomSpeed = 1;
}
if (gamePadZoomSpeed > 30) {
gamePadZoomSpeed = 30;
}
const transform = pz.getTransform();
const horizontalShift = gp.axes[0] * -1 * gamePadZoomSpeed;
const verticalShift = gp.axes[1] * -1 * gamePadZoomSpeed;
if (horizontalShift || verticalShift) {
pz.moveTo(
transform!.x + horizontalShift,
transform!.y + verticalShift
);
}
}
}
if (stats) {
stats.end();
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
});
return {
root,
loadedPercent,
panzoom,
anchors,
panToAnchor,
scrolls,
audioAreas,
bbox,
mousePosition,
};
},
});
function processAnchors(document: XMLDocument): SVGRectElement[] {
const result: SVGRectElement[] = [];
Array.from(document.getElementsByTagName("rect"))
.filter((el) => el.id.startsWith("anchor"))
.forEach((anchor) => {
console.debug(`[SVG/ANCHORS] Found anchor #${anchor.id}.`);
anchor.classList.add("internal");
result.push(anchor);
});
return result;
}
async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
const ratio = (svg as any).clientWidth / (svg as any).viewBox.baseVal.width;
return Promise.all(
Array.from(svg.getElementsByTagName("image"))
.filter((el) =>
Array.from(el.children).some((el) => el.tagName == "desc")
)
.map(async (el) => {
const descNode = Array.from(el.children).find(
(el) => el.tagName == "desc"
);
console.debug(
`[SVG/VIDEOSCROLLS] Found video scroll #${el.id}: ${descNode?.textContent}`
);
const [directionString, filesURL] = descNode!.textContent!.split("\n");
const directions: VideoScrollDirection[] = directionString
.split(" ")
.map((direction) => {
if (
!Object.values(VideoScrollDirection).includes(
direction as VideoScrollDirection
)
) {
console.error(
`Unknown direction definition: "${direction}" (in #${el.id})`
);
return false;
}
return direction as VideoScrollDirection;
})
.filter((d) => Boolean(d)) as VideoScrollDirection[];
console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
const fileFetch = await fetch(`content/${filesURL}`);
const preURL = fileFetch.url.replace(/\/files.lst$/, "");
const files = (await fileFetch.text())
.split("\n")
.filter(Boolean)
.map((str) => `${preURL}/${str}`);
let x = el.x.baseVal.value;
let y = el.y.baseVal.value;
let w = el.width.baseVal.value;
let h = el.height.baseVal.value;
let angle = 0;
const transform = el.attributes.getNamedItem("transform");
const rotateResult = /rotate\((-?[0-9.]+)\)/.exec(
transform?.value || ""
);
if (rotateResult) {
angle = parseFloat(rotateResult[1]);
const [ncx, ncy] = rotate(x + w / 2, y + h / 2, 0, 0, angle);
x = ncx - w / 2;
y = ncy - h / 2;
}
return {
id: el.id,
top: y * ratio,
left: x * ratio,
angle,
width: w * ratio,
height: h * ratio,
directions,
files,
};
})
);
}
function processAudio(svg: XMLDocument): AudioAreaDef[] {
const circles: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
svg.getElementsByTagName("circle")
);
const ellipses: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
svg.getElementsByTagName("ellipse")
);
return circles
.concat(ellipses)
.filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
.map((el) => {
const descNode = Array.from(el.children).find(
(el) => el.tagName == "desc"
);
console.debug(
`[SVG/AUDIOAREAS] Found audio area #${el.id}: ${descNode?.textContent}`
);
const audioSrc = descNode!.textContent!.trim();
const radius = el.hasAttribute("r")
? (el as SVGCircleElement).r.baseVal.value
: ((el as SVGEllipseElement).rx.baseVal.value +
(el as SVGEllipseElement).ry.baseVal.value) /
2;
el.classList.add("internal");
return {
id: el.id,
cx: el.cx.baseVal.value,
cy: el.cy.baseVal.value,
radius,
src: `content/${audioSrc}`,
};
});
}
function processHyperlinks(
svg: XMLDocument
): { anchor: [string, SVGAElement][]; hyper: SVGAElement[] } {
const anchor: [string, SVGAElement][] = [];
const hyper: SVGAElement[] = [];
Array.from(svg.getElementsByTagName("a")).forEach((el) => {
if (el.getAttribute("xlink:href")?.startsWith("anchor")) {
anchor.push([
el.getAttribute("xlink:href") as string,
(el as unknown) as SVGAElement,
]);
el.setAttribute("xlink:href", "#");
} else {
el.setAttribute("target", "_blank");
hyper.push((el as unknown) as SVGAElement);
}
});
return { anchor, hyper };
}
function processStart(svg: XMLDocument): SVGRectElement | null {
const start = svg.getElementById("start");
if (start) {
start.classList.add("internal");
}
return start as SVGRectElement | null;
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.svg-content svg {
overflow: visible;
}
.svg-content svg .internal {
visibility: hidden;
}
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: black;
transition: opacity 0.5s;
opacity: 1;
}
.loading-screen.loaded {
opacity: 0 !important;
}
.loading-bar {
height: 6px;
background: white;
margin: calc(50vh - 3px) auto;
transition: width 0.2s;
}
.dev {
display: none;
}
.devpanel {
position: fixed;
top: 0;
right: 0;
z-index: 999;
color: white;
background: #000000aa;
border: 2px solid white;
font-family: monospace;
padding: 1em 2em;
}
.devpanel div {
display: flex;
justify-content: space-between;
}
.devpanel label {
float: right;
}
.devpanel div span {
margin: 0 0.5em;
}
</style>

View file

@ -1,180 +0,0 @@
<template>
<div class="video-scroll" ref="root" v-if="definition.directions.length > 0">
<img
class="visible displayed loaded"
:src="definition.files[0]"
:style="{
top: `${Math.round(definition.top)}px`,
left: `${Math.round(definition.left)}px`,
width: isHorizontal ? `${Math.round(definition.width)}px` : 'auto',
height: isVertical ? `${Math.round(definition.height)}px` : 'auto',
transform: `rotate(${definition.angle}deg)`,
}"
/>
<!--suppress RequiredAttributes -->
<img
v-for="(file, idx) in dynamicFiles"
:key="`${idx}_${file.src}`"
:data-src="file.src"
:style="{
top: `${Math.round(file.top)}px`,
left: `${Math.round(file.left)}px`,
width: `${Math.round(definition.width)}px`,
height: `${Math.round(definition.height)}px`,
transform: `rotate(${definition.angle}deg)`,
}"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { rotate } from "@/utils";
import { queueImageForLoading } from "@/services/ImageLoader";
export default defineComponent({
name: "VideoScroll",
props: {
definition: {
type: Object as PropType<VideoScrollDef>,
required: true,
},
},
computed: {
dynamicFiles(): { top: number; left: number; src: string }[] {
return this.definition.files.slice(1).map((src: string, idx: number) => {
const cy =
this.definition.top +
(this.isVertical
? this.definition.height * (idx + 1) * this.verticalDirection
: 0);
const cx =
this.definition.left +
(this.isHorizontal
? this.definition.width * (idx + 1) * this.horizontalDirection
: 0);
const [left, top] = rotate(
cx,
cy,
this.definition.left,
this.definition.top,
this.definition.angle
);
return { top, left, src };
});
},
isHorizontal(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) =>
dir === VideoScrollDirection.LEFT ||
dir === VideoScrollDirection.RIGHT
);
},
isVertical(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) =>
dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
);
},
horizontalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.RIGHT)
? 1
: -1;
},
verticalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.DOWN)
? 1
: -1;
},
},
methods: {
handleImageLoad(element: HTMLImageElement) {
// Setup image display when loaded
element.classList.add("displayed");
element.classList.add("loaded");
// Adjust dimensions based on scroll direction
if (this.isHorizontal) {
element.style.height = "auto";
} else {
element.style.width = "auto";
}
}
},
mounted() {
const observer = new IntersectionObserver((entries, _) => {
entries.forEach((entry) => {
const element = entry.target as HTMLImageElement;
if (entry.isIntersecting) {
element.classList.add("visible");
if (!element.src && element.dataset.src) {
// Queue the image for loading through the global service
const self = this;
queueImageForLoading(element, function() {
self.handleImageLoad(element);
});
// Add a fallback to show the image after a timeout even if not fully loaded
setTimeout(() => {
if (!element.classList.contains("loaded")) {
element.classList.add("displayed");
}
}, 3000);
}
} else {
element.classList.remove("visible");
}
});
});
if (this.$refs.root) {
Array.from((this.$refs.root as Element).children).forEach((el) => {
observer.observe(el);
});
}
},
});
export enum VideoScrollDirection {
RIGHT = "right",
LEFT = "left",
UP = "up",
DOWN = "down",
}
export interface VideoScrollDef {
id: string;
top: number;
left: number;
angle: number;
width: number;
height: number;
directions: VideoScrollDirection[];
files: string[];
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.video-scroll img {
position: absolute;
image-rendering: optimizeSpeed;
background: grey;
visibility: hidden;
opacity: 0;
transition: opacity 0.5s;
}
.video-scroll img.visible {
visibility: visible !important;
}
.video-scroll img.displayed {
opacity: 1 !important;
}
.video-scroll img.loaded {
background: transparent !important;
}
</style>

6
src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
#![warn(clippy::all, rust_2018_idioms)]
mod app;
pub mod svg;
pub use app::TemplateApp;

76
src/main.rs Normal file
View file

@ -0,0 +1,76 @@
#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("debug"),
)
.init();
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 300.0])
.with_min_inner_size([300.0, 220.0])
.with_icon(
// NOTE: Adding an icon is optional
eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
.expect("Failed to load icon"),
),
vsync: false, // Disable vsync to test if it affects input smoothness
..Default::default()
};
eframe::run_native(
"Line and Surface",
native_options,
Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))),
)
}
// When compiling to web using trunk:
#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast as _;
// Redirect `log` message to `console.log` and friends:
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
let web_options = eframe::WebOptions::default();
wasm_bindgen_futures::spawn_local(async {
let document = web_sys::window()
.expect("No window")
.document()
.expect("No document");
let canvas = document
.get_element_by_id("the_canvas_id")
.expect("Failed to find the_canvas_id")
.dyn_into::<web_sys::HtmlCanvasElement>()
.expect("the_canvas_id was not a HtmlCanvasElement");
let start_result = eframe::WebRunner::new()
.start(
canvas,
web_options,
Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))),
)
.await;
// Remove the loading text and spinner:
if let Some(loading_text) = document.get_element_by_id("loading_text") {
match start_result {
Ok(_) => {
loading_text.remove();
}
Err(e) => {
loading_text.set_inner_html(
"<p> The app has crashed. See the developer console for details. </p>",
);
panic!("Failed to start eframe: {e:?}");
}
}
}
});
}

View file

@ -1,5 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'
createApp(App).use(store).mount('#app')

View file

@ -1,80 +0,0 @@
/**
* Global image loading queue service to prevent hitting browser connection limits
*/
// Configuration
const MAX_CONCURRENT_LOADS = 5;
// State
let activeLoads = 0;
const imageQueue: Array<{
element: HTMLImageElement;
onComplete: () => void;
}> = [];
/**
* Queue an image for loading, respecting the global concurrent loading limit
*/
export function queueImageForLoading(
element: HTMLImageElement,
onComplete?: () => void
) {
if (!element.dataset.src) {
console.warn("[ImageLoader] Element has no data-src attribute");
return;
}
// Add to queue
imageQueue.push({
element,
onComplete: onComplete || (() => {}),
});
// Try to process queue
processQueue();
}
/**
* Process the next items in the queue if we have capacity
*/
function processQueue() {
// Load more images if we have capacity and images in the queue
while (activeLoads < MAX_CONCURRENT_LOADS && imageQueue.length > 0) {
const next = imageQueue.shift();
if (next) {
loadImage(next.element, next.onComplete);
}
}
}
/**
* Internal function to handle the actual image loading
*/
function loadImage(element: HTMLImageElement, onComplete: () => void) {
// Increment active loads counter
activeLoads++;
const src = element.dataset.src;
console.debug(`[ImageLoader] Loading ${src}`);
// Start loading the image
element.src = src!;
// Handle load completion
const handleCompletion = () => {
activeLoads--;
onComplete();
processQueue();
};
// Set handlers
element.onload = () => {
console.debug(`[ImageLoader] Loaded ${src}`);
handleCompletion();
};
element.onerror = () => {
console.error(`[ImageLoader] Failed to load ${src}`);
handleCompletion();
};
}

6
src/shims-vue.d.ts vendored
View file

@ -1,6 +0,0 @@
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View file

@ -1,12 +0,0 @@
import { createStore } from 'vuex'
export default createStore({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})

482
src/svg.rs Normal file
View file

@ -0,0 +1,482 @@
//! SVG parsing module for extracting special elements from SVG files.
use quick_xml::events::Event;
use quick_xml::Reader;
use std::fs;
/// Trait for elements that can be rendered with a bounding box.
pub trait Renderable {
/// Returns the bounding box: (x, y, width, height) in SVG coordinates.
fn bounds(&self) -> (f32, f32, f32, f32);
}
/// An `<image>` element with a `<desc>` child (video scroll area).
#[derive(Debug, Clone)]
pub struct VideoScroll {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub desc: String,
}
impl Renderable for VideoScroll {
fn bounds(&self) -> (f32, f32, f32, f32) {
(self.x, self.y, self.width, self.height)
}
}
/// A `<circle>` or `<ellipse>` element with a `<desc>` child (audio area).
#[derive(Debug, Clone)]
pub struct AudioArea {
pub cx: f32,
pub cy: f32,
pub radius: f32,
pub desc: String,
}
impl Renderable for AudioArea {
fn bounds(&self) -> (f32, f32, f32, f32) {
(
self.cx - self.radius,
self.cy - self.radius,
self.radius * 2.0,
self.radius * 2.0,
)
}
}
/// A `<rect>` element with id starting with "anchor".
#[derive(Debug, Clone)]
pub struct Anchor {
pub id: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Renderable for Anchor {
fn bounds(&self) -> (f32, f32, f32, f32) {
(self.x, self.y, self.width, self.height)
}
}
/// A `<text>` element.
#[derive(Debug, Clone)]
pub struct TextElement {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub content: String,
}
impl Renderable for TextElement {
fn bounds(&self) -> (f32, f32, f32, f32) {
(self.x, self.y, self.width, self.height)
}
}
/// Container for all parsed SVG content.
#[derive(Debug, Clone, Default)]
pub struct SvgContent {
pub video_scrolls: Vec<VideoScroll>,
pub audio_areas: Vec<AudioArea>,
pub anchors: Vec<Anchor>,
pub texts: Vec<TextElement>,
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
}
/// State for tracking current element during parsing.
#[derive(Debug, Clone)]
enum PendingElement {
Image {
x: f32,
y: f32,
width: f32,
height: f32,
},
Circle {
cx: f32,
cy: f32,
r: f32,
},
Ellipse {
cx: f32,
cy: f32,
rx: f32,
ry: f32,
},
}
impl SvgContent {
/// Parse an SVG file and extract special elements.
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
Self::parse(&content)
}
/// Parse SVG content from a string.
pub fn parse(content: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut reader = Reader::from_str(content);
reader.config_mut().trim_text(true);
let mut svg_content = Self::default();
let mut buf = Vec::new();
// Stack to track nested elements and their pending state
let mut pending: Option<PendingElement> = None;
let mut in_text = false;
let mut text_x = 0.0f32;
let mut text_y = 0.0f32;
let mut text_content = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name_bytes = e.name();
let name = String::from_utf8_lossy(name_bytes.as_ref());
match name.as_ref() {
"svg" => {
// Extract viewBox
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"viewBox" {
let value = String::from_utf8_lossy(&attr.value);
let parts: Vec<f32> = value
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() == 4 {
svg_content.viewbox =
Some((parts[0], parts[1], parts[2], parts[3]));
}
}
}
}
"image" => {
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut width = 0.0f32;
let mut height = 0.0f32;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"x" => x = value.parse().unwrap_or(0.0),
"y" => y = value.parse().unwrap_or(0.0),
"width" => width = value.parse().unwrap_or(0.0),
"height" => height = value.parse().unwrap_or(0.0),
_ => {}
}
}
pending = Some(PendingElement::Image {
x,
y,
width,
height,
});
}
"circle" => {
let mut cx = 0.0f32;
let mut cy = 0.0f32;
let mut r = 0.0f32;
let mut has_id_with_desc_potential = false;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"cx" => cx = value.parse().unwrap_or(0.0),
"cy" => cy = value.parse().unwrap_or(0.0),
"r" => r = value.parse().unwrap_or(0.0),
"id" => has_id_with_desc_potential = true,
_ => {}
}
}
let _: bool = has_id_with_desc_potential; // May check desc child later
pending = Some(PendingElement::Circle { cx, cy, r });
}
"ellipse" => {
let mut cx = 0.0f32;
let mut cy = 0.0f32;
let mut rx = 0.0f32;
let mut ry = 0.0f32;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"cx" => cx = value.parse().unwrap_or(0.0),
"cy" => cy = value.parse().unwrap_or(0.0),
"rx" => rx = value.parse().unwrap_or(0.0),
"ry" => ry = value.parse().unwrap_or(0.0),
_ => {}
}
}
pending = Some(PendingElement::Ellipse { cx, cy, rx, ry });
}
"rect" => {
let mut id = String::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut width = 0.0f32;
let mut height = 0.0f32;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"id" => id = value.to_string(),
"x" => x = value.parse().unwrap_or(0.0),
"y" => y = value.parse().unwrap_or(0.0),
"width" => width = value.parse().unwrap_or(0.0),
"height" => height = value.parse().unwrap_or(0.0),
_ => {}
}
}
if id.starts_with("anchor") {
svg_content.anchors.push(Anchor {
id,
x,
y,
width,
height,
});
}
}
"text" => {
in_text = true;
text_content.clear();
text_x = 0.0;
text_y = 0.0;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"x" => text_x = value.parse().unwrap_or(0.0),
"y" => text_y = value.parse().unwrap_or(0.0),
_ => {}
}
}
}
// desc content will be captured in Text event
_ => {}
}
}
Ok(Event::Empty(ref e)) => {
// Self-closing elements
let name_bytes = e.name();
let name = String::from_utf8_lossy(name_bytes.as_ref());
if name == "rect" {
let mut id = String::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut width = 0.0f32;
let mut height = 0.0f32;
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref());
let value = String::from_utf8_lossy(&attr.value);
match key.as_ref() {
"id" => id = value.to_string(),
"x" => x = value.parse().unwrap_or(0.0),
"y" => y = value.parse().unwrap_or(0.0),
"width" => width = value.parse().unwrap_or(0.0),
"height" => height = value.parse().unwrap_or(0.0),
_ => {}
}
}
if id.starts_with("anchor") {
svg_content.anchors.push(Anchor {
id,
x,
y,
width,
height,
});
}
}
}
Ok(Event::Text(ref e)) => {
let text = e.unescape().unwrap_or_default();
if in_text {
text_content.push_str(&text);
} else if let Some(ref p) = pending {
// This is desc content
let desc = text.trim().to_owned();
if !desc.is_empty() {
match p {
PendingElement::Image {
x,
y,
width,
height,
} => {
svg_content.video_scrolls.push(VideoScroll {
x: *x,
y: *y,
width: *width,
height: *height,
desc,
});
}
PendingElement::Circle { cx, cy, r } => {
svg_content.audio_areas.push(AudioArea {
cx: *cx,
cy: *cy,
radius: *r,
desc,
});
}
PendingElement::Ellipse { cx, cy, rx, ry } => {
svg_content.audio_areas.push(AudioArea {
cx: *cx,
cy: *cy,
radius: (*rx + *ry) / 2.0,
desc,
});
}
}
}
}
}
Ok(Event::End(ref e)) => {
let name_bytes = e.name();
let name = String::from_utf8_lossy(name_bytes.as_ref());
match name.as_ref() {
"image" | "circle" | "ellipse" => {
pending = None;
}
"text" => {
if in_text {
let content = text_content.trim().to_owned();
// Estimate width/height based on content length
// Using rough character width of 8px and height of 16px
let estimated_width = content.len() as f32 * 8.0;
let estimated_height = 16.0;
svg_content.texts.push(TextElement {
x: text_x,
y: text_y - estimated_height, // SVG text y is baseline
width: estimated_width,
height: estimated_height,
content,
});
in_text = false;
text_content.clear();
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => {
log::warn!(
"Error parsing SVG at position {}: {:?}",
reader.error_position(),
e
);
break;
}
_ => {}
}
buf.clear();
}
Ok(svg_content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_video_scroll() {
let svg = r#"
<svg viewBox="0 0 1000 1000">
<image x="100" y="200" width="300" height="400">
<desc>horizontal video.mp4</desc>
</image>
</svg>
"#;
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
assert_eq!(content.video_scrolls.len(), 1);
let vs = &content.video_scrolls[0];
assert_eq!(vs.x, 100.0);
assert_eq!(vs.y, 200.0);
assert_eq!(vs.width, 300.0);
assert_eq!(vs.height, 400.0);
assert_eq!(vs.desc, "horizontal video.mp4");
}
#[test]
fn test_parse_audio_area_circle() {
let svg = r#"
<svg viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="100">
<desc>ambient.mp3</desc>
</circle>
</svg>
"#;
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
assert_eq!(content.audio_areas.len(), 1);
let aa = &content.audio_areas[0];
assert_eq!(aa.cx, 500.0);
assert_eq!(aa.cy, 500.0);
assert_eq!(aa.radius, 100.0);
let (x, y, w, h) = aa.bounds();
assert_eq!(x, 400.0);
assert_eq!(y, 400.0);
assert_eq!(w, 200.0);
assert_eq!(h, 200.0);
}
#[test]
fn test_parse_anchor() {
let svg = r#"
<svg viewBox="0 0 1000 1000">
<rect id="anchor-home" x="10" y="20" width="30" height="40" />
</svg>
"#;
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
assert_eq!(content.anchors.len(), 1);
let anchor = &content.anchors[0];
assert_eq!(anchor.id, "anchor-home");
assert_eq!(anchor.x, 10.0);
assert_eq!(anchor.y, 20.0);
}
#[test]
fn test_parse_text() {
let svg = r#"
<svg viewBox="0 0 1000 1000">
<text x="100" y="200">Hello World</text>
</svg>
"#;
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
assert_eq!(content.texts.len(), 1);
let text = &content.texts[0];
assert_eq!(text.x, 100.0);
assert_eq!(text.content, "Hello World");
}
#[test]
fn test_viewbox() {
let svg = r#"<svg viewBox="0 0 1920 1080"></svg>"#;
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0)));
}
}

View file

@ -1,8 +0,0 @@
export function rotate(x: number, y: number, cx: number, cy: number, angleDegrees: number): [number, number] {
const angleRad = (Math.PI / 180) * angleDegrees * -1;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const nx = (cos * (x - cx)) + (sin * (y - cy)) + cx;
const ny = (cos * (y - cy)) - (sin * (x - cx)) + cy;
return [nx, ny];
}

View file

@ -1,39 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View file

@ -1,6 +0,0 @@
module.exports = {
// publicPath: process.env.VUE_APP_BASE_URL || '/las/',
devServer: {
hot: false,
},
};