Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c8e8129a5 | |||
| a5f1846491 | |||
| 261ac832b2 | |||
| d6e02406d8 | |||
| 637eaa7f55 | |||
| 258c812383 | |||
| 7a84ef1b8a | |||
| a13fd66f47 | |||
| c909d12d6a |
59 changed files with 4552 additions and 7169 deletions
3
.browserslistrc
Normal file
3
.browserslistrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
1
.dagger/.gitattributes
vendored
1
.dagger/.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
||||||
/sdk/** linguist-generated
|
|
||||||
4
.dagger/.gitignore
vendored
4
.dagger/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
/sdk
|
|
||||||
/**/node_modules/**
|
|
||||||
/**/.pnpm-store/**
|
|
||||||
/.env
|
|
||||||
25
.dagger/package-lock.json
generated
25
.dagger/package-lock.json
generated
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"name": ".dagger",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"typescript": "5.9.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"type": "module"
|
|
||||||
,"dependencies":{"typescript":"5.9.3"}}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import {
|
|
||||||
dag,
|
|
||||||
Container,
|
|
||||||
Directory,
|
|
||||||
File,
|
|
||||||
object,
|
|
||||||
func,
|
|
||||||
argument,
|
|
||||||
} from "@dagger.io/dagger";
|
|
||||||
|
|
||||||
const RUST_IMAGE = "rust:1.85";
|
|
||||||
const WORKDIR = "/workspace";
|
|
||||||
|
|
||||||
@object()
|
|
||||||
export class LineAndSurface {
|
|
||||||
private baseRust(source: Directory): Container {
|
|
||||||
return dag
|
|
||||||
.container()
|
|
||||||
.from(RUST_IMAGE)
|
|
||||||
.withEnvVariable("CARGO_HOME", "/root/.cargo")
|
|
||||||
.withMountedCache("/root/.cargo", dag.cacheVolume("cargo-cache"))
|
|
||||||
.withMountedCache(
|
|
||||||
`${WORKDIR}/target`,
|
|
||||||
dag.cacheVolume("cargo-target"),
|
|
||||||
)
|
|
||||||
.withWorkdir(WORKDIR)
|
|
||||||
.withDirectory(WORKDIR, source)
|
|
||||||
.withExec(["cargo", "fetch", "--locked"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the native desktop binary (release).
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async nativeBuild(
|
|
||||||
@argument({
|
|
||||||
defaultPath: "/",
|
|
||||||
ignore: [".git/**", ".dagger/**", "target/**", "node_modules/**"],
|
|
||||||
})
|
|
||||||
source: Directory,
|
|
||||||
): Promise<File> {
|
|
||||||
const ctr = this.baseRust(source)
|
|
||||||
.withExec(["cargo", "build", "--release"])
|
|
||||||
.withExec(["mkdir", "-p", "/out"])
|
|
||||||
.withExec(["cp", "-v", `${WORKDIR}/target/release/las`, "/out/las"]);
|
|
||||||
|
|
||||||
return ctr.file("/out/las");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the web (wasm) bundle using Trunk.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async webBuild(
|
|
||||||
@argument({
|
|
||||||
defaultPath: "/",
|
|
||||||
ignore: [".git/**", ".dagger/**", "target/**", "node_modules/**"],
|
|
||||||
})
|
|
||||||
source: Directory,
|
|
||||||
): Promise<Directory> {
|
|
||||||
const ctr = this.baseRust(source)
|
|
||||||
.withExec(["rustup", "target", "add", "wasm32-unknown-unknown"])
|
|
||||||
.withExec(["cargo", "install", "trunk"])
|
|
||||||
.withExec(["trunk", "build", "--release", "--dist", "/out"]);
|
|
||||||
|
|
||||||
return ctr.directory("/out");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"paths": {
|
|
||||||
"@dagger.io/dagger": ["./sdk/index.ts"],
|
|
||||||
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
|
||||||
# yarn lockfile v1
|
|
||||||
|
|
||||||
|
|
||||||
typescript@5.9.3:
|
|
||||||
version "5.9.3"
|
|
||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
|
|
||||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
|
||||||
3
.gitattributes
vendored
3
.gitattributes
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
**/*.ico filter=lfs diff=lfs merge=lfs -text
|
|
||||||
**/*.png filter=lfs diff=lfs merge=lfs -text
|
|
||||||
**/*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,9 +1,4 @@
|
||||||
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
# Rust compile target directories:
|
public/content
|
||||||
target
|
|
||||||
target_ra
|
|
||||||
target_wasm
|
|
||||||
|
|
||||||
# https://github.com/lycheeverse/lychee
|
|
||||||
.lycheecache
|
|
||||||
|
|
|
||||||
47
.gitlab-ci.yml
Normal file
47
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
build site:
|
||||||
|
image: node:lts
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- yarn install --cache-folder .yarn
|
||||||
|
- yarn build
|
||||||
|
- rm dist/.gitkeep # Necessary because `/public` is empty at the moment
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- .yarn
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- dist
|
||||||
|
|
||||||
|
deploy dev:
|
||||||
|
image: instrumentisto/rsync-ssh
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
refs:
|
||||||
|
- develop
|
||||||
|
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}/dev/"
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
3457
Cargo.lock
generated
3457
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
243
Cargo.toml
243
Cargo.toml
|
|
@ -1,243 +0,0 @@
|
||||||
[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"
|
|
||||||
ab_glyph = "0.2"
|
|
||||||
|
|
||||||
# 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 = { version = "0.3.70", features = ["Performance", "Response", "Window"] } # 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'
|
|
||||||
76
README.md
76
README.md
|
|
@ -1,3 +1,79 @@
|
||||||
# Line and Surface
|
# Line and Surface
|
||||||
|
|
||||||
Web presentation of the Line and Surface project.
|
Web presentation of the Line and Surface project.
|
||||||
|
|
||||||
|
Live at https://sdbs.cz/las/.
|
||||||
|
|
||||||
|
|
||||||
|
## Authoring
|
||||||
|
|
||||||
|
**(Taken directly from [Digital Garage Anabasis](https://garage.sdbs.cz/tools.inkscape.las.authoring.md) - yet to be tidied up)**
|
||||||
|
|
||||||
|
### links
|
||||||
|
#### anchors
|
||||||
|
- object ---> right click ---> create link
|
||||||
|
- id without hashtag
|
||||||
|
|
||||||
|
- anchor <---description <---#anchor_id <---rectangle
|
||||||
|
|
||||||
|
#### hyperlink
|
||||||
|
- object ---> right click ---> create link
|
||||||
|
- href >>> url
|
||||||
|
|
||||||
|
#### intro / start
|
||||||
|
- square
|
||||||
|
- object properties
|
||||||
|
--> id `start`
|
||||||
|
|
||||||
|
|
||||||
|
### movies
|
||||||
|
- image file --> object properties
|
||||||
|
- ---> description
|
||||||
|
- `down / up /... / up left / right down /....`
|
||||||
|
- new line
|
||||||
|
- `motion_source/sutr1/files.lst`
|
||||||
|
- ---> ! set button !
|
||||||
|
- `down
|
||||||
|
motion_source/sutr1/files.lst
|
||||||
|
`
|
||||||
|
|
||||||
|
|
||||||
|
#### linux list
|
||||||
|
```sh
|
||||||
|
ls *.png > files.lst
|
||||||
|
```
|
||||||
|
|
||||||
|
#### windows list
|
||||||
|
The method is the same as for all versions of Windows, starting with Windows 3.1 (from 1985!):
|
||||||
|
|
||||||
|
1. Open File Explorer.
|
||||||
|
2. Navigate to the folder under scrutiny.
|
||||||
|
3. Press Ctrl+L
|
||||||
|
4. Type this command (or use copy/paste) and press Enter:
|
||||||
|
cmd /c dir /b > "%temp%\\Dir.txt" & notepad "%temp%\\Dir.txt"
|
||||||
|
|
||||||
|
### sound
|
||||||
|
- circle (ELLIPSE NONONO)
|
||||||
|
- object properties ---> desription
|
||||||
|
- sound_source/xxx.mp3
|
||||||
|
- ---> ! set button !
|
||||||
|
|
||||||
|
|
||||||
|
### Interaction manual
|
||||||
|
double click - fullscreen
|
||||||
|
click with middle button - grab
|
||||||
|
spacebar - anchor //intro
|
||||||
|
mouse pointer --> edge - edge scrolling [fullscreen must be on]
|
||||||
|
|
||||||
|
|
||||||
|
### image optimizations
|
||||||
|
|
||||||
|
#### png
|
||||||
|
`parallel --lb --tag optipng -o 5 -i 1 ::: **/*.png`
|
||||||
|
|
||||||
|
- `parallel` = process in [parallel](https://www.gnu.org/software/parallel/)
|
||||||
|
- `-o 5` = ridiculously high optimization
|
||||||
|
- `-i 1` = turn on [interlacing](https://blog.codinghorror.com/progressive-image-rendering/)
|
||||||
|
|
||||||
|
#### jpeg
|
||||||
|
`parallel --lb --tag jpegoptim --all-progressive --force ::: **/*.jpg`
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[build]
|
|
||||||
filehash = false
|
|
||||||
270
asset.svg
270
asset.svg
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.7 MiB |
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5
|
|
||||||
size 569208
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6c61457f43af60ad7cf5fb1420d2e6f484f85e1185c38f351e57531a51ca1a5e
|
|
||||||
size 15406
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:87c993335fd0b0bf16a276627d205ebd703c2a9a762035973cef8032cd4df6e3
|
|
||||||
size 321266
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b33a7d4fdbf76834bcf39551a63b87e7b32cf6d1ccb2d0d3cbcf88bf2ae4828a
|
|
||||||
size 48330
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c6ab5ae62953d1093ac273d6cc39a9c9015986bee1aed1f7cf013a0e6e3fe030
|
|
||||||
size 21131
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e1a2353165288b7762da7c4fc4511ad409aff46bc2fa05f447bb0cf428d1917c
|
|
||||||
size 130625
|
|
||||||
25
assets/sw.js
25
assets/sw.js
|
|
@ -1,25 +0,0 @@
|
||||||
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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
11
check.sh
11
check.sh
|
|
@ -1,11 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"name": "line-and-surface",
|
|
||||||
"engineVersion": "v0.19.10",
|
|
||||||
"sdk": {
|
|
||||||
"source": "typescript"
|
|
||||||
},
|
|
||||||
"source": ".dagger"
|
|
||||||
}
|
|
||||||
164
index.html
164
index.html
|
|
@ -1,149 +1,27 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
|
||||||
<!-- Disable zooming: -->
|
<title>Line and Surface</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
</head>
|
||||||
|
|
||||||
<head>
|
<body>
|
||||||
<!-- change this to your project name -->
|
<noscript>
|
||||||
<title>las-rs</title>
|
<strong
|
||||||
|
>We're sorry but Line and Surface doesn't work properly without
|
||||||
|
JavaScript enabled. Please enable it to continue.</strong
|
||||||
|
>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
<!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
|
<div id="app"></div>
|
||||||
<link data-trunk rel="rust" data-wasm-opt="2" />
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<!-- 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 data-trunk rel="copy-file" href="content/intro.svg" data-target-path="content"/>
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<script
|
||||||
|
data-goatcounter="https://las.goatcounter.com/count"
|
||||||
|
async
|
||||||
|
src="//gc.zgo.at/count.js"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
<!-- Powered by egui: https://github.com/emilk/egui/ -->
|
|
||||||
|
|
|
||||||
572
lint_intro.ts
572
lint_intro.ts
|
|
@ -1,572 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { Command } from "@commander-js/extra-typings";
|
|
||||||
import { DOMParser, Document, Element } from "@xmldom/xmldom";
|
|
||||||
|
|
||||||
interface ElementInfo {
|
|
||||||
type: string;
|
|
||||||
element: Element;
|
|
||||||
properties: Record<string, string>;
|
|
||||||
descriptions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LintWarning {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
element?: ElementInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LintContext {
|
|
||||||
filePath: string;
|
|
||||||
fileDir: string;
|
|
||||||
svgDoc: Document;
|
|
||||||
elements: ElementInfo[];
|
|
||||||
warnings: LintWarning[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup the command line interface
|
|
||||||
const program = new Command()
|
|
||||||
.name("svg-linter")
|
|
||||||
.description("Lints SVG files according to our constraints")
|
|
||||||
.version("0.1.0")
|
|
||||||
.argument("<file>", "Path to the SVG file to lint")
|
|
||||||
.option(
|
|
||||||
"--inspect",
|
|
||||||
"Inspect mode - outputs all SVG elements and their properties"
|
|
||||||
)
|
|
||||||
.parse();
|
|
||||||
|
|
||||||
const options = program.opts();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads and parses an SVG file
|
|
||||||
* @param filePath Path to the SVG file
|
|
||||||
* @returns The parsed SVG document
|
|
||||||
*/
|
|
||||||
function parseSvgFile(filePath: string): Document {
|
|
||||||
try {
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
|
||||||
const svgContent = fs.readFileSync(resolvedPath, "utf-8");
|
|
||||||
const parser = new DOMParser();
|
|
||||||
return parser.parseFromString(svgContent, "image/svg+xml");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error reading or parsing SVG file: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts properties from an SVG element
|
|
||||||
* @param element The SVG element to extract properties from
|
|
||||||
* @returns Record of property name to value
|
|
||||||
*/
|
|
||||||
function extractElementProperties(element: Element): Record<string, string> {
|
|
||||||
const properties: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < element.attributes.length; i++) {
|
|
||||||
const attr = element.attributes[i];
|
|
||||||
properties[attr.name] = attr.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts all elements of a specific type from an SVG document
|
|
||||||
* @param doc The SVG document
|
|
||||||
* @param elementType The element type to extract
|
|
||||||
* @returns Array of elements with their properties
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Extract desc elements from a parent element
|
|
||||||
* @param element The parent element
|
|
||||||
* @returns Array of text content from desc elements
|
|
||||||
*/
|
|
||||||
function extractDescElements(element: Element): string[] {
|
|
||||||
const descriptions: string[] = [];
|
|
||||||
const descElements = element.getElementsByTagName("desc");
|
|
||||||
|
|
||||||
for (let i = 0; i < descElements.length; i++) {
|
|
||||||
const descElement = descElements[i];
|
|
||||||
const textContent = descElement.textContent;
|
|
||||||
if (textContent) {
|
|
||||||
descriptions.push(textContent.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return descriptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractElements(doc: Document, elementType: string): ElementInfo[] {
|
|
||||||
const elements = doc.getElementsByTagName(elementType);
|
|
||||||
const result: ElementInfo[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
|
||||||
const element = elements[i];
|
|
||||||
result.push({
|
|
||||||
type: elementType,
|
|
||||||
element: element,
|
|
||||||
properties: extractElementProperties(element),
|
|
||||||
descriptions: extractDescElements(element),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Linting functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Lint: There should be exactly one element with an id "start", and it should be a rect
|
|
||||||
function lintStartRect(context: LintContext): void {
|
|
||||||
const startElements = context.elements.filter(
|
|
||||||
(el) => el.properties.id === "start"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (startElements.length === 0) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "START_MISSING",
|
|
||||||
message:
|
|
||||||
"No element with id 'start' found. One rect with id='start' is required.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startElements.length > 1) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "START_DUPLICATE",
|
|
||||||
message: `Found ${startElements.length} elements with id 'start'. Only one is allowed.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nonRectStarts = startElements.filter((el) => el.type !== "rect");
|
|
||||||
if (nonRectStarts.length > 0) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "START_NOT_RECT",
|
|
||||||
message: `Element with id 'start' must be a rect, but found: ${nonRectStarts
|
|
||||||
.map((el) => el.type)
|
|
||||||
.join(", ")}.`,
|
|
||||||
element: nonRectStarts[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: For circles with a description element, the description should point to a relative path to an existing file
|
|
||||||
function lintCircleDescriptions(context: LintContext): void {
|
|
||||||
const circlesWithDesc = context.elements.filter(
|
|
||||||
(el) => el.type === "circle" && el.descriptions.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
circlesWithDesc.forEach((circle) => {
|
|
||||||
circle.descriptions.forEach((desc) => {
|
|
||||||
// Check if description appears to be a file path
|
|
||||||
if (desc.includes("/") || desc.includes(".")) {
|
|
||||||
// Try to resolve the path relative to the SVG file's directory
|
|
||||||
const descPath = path.resolve(context.fileDir, desc);
|
|
||||||
if (!fs.existsSync(descPath)) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "CIRCLE_DESC_FILE_MISSING",
|
|
||||||
message: `Circle (id=${circle.properties.id ||
|
|
||||||
"no-id"}) has description pointing to non-existent file: ${desc}`,
|
|
||||||
element: circle,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: If there's an ellipse with any description, emit a warning
|
|
||||||
function lintEllipseDescriptions(context: LintContext): void {
|
|
||||||
const ellipsesWithDesc = context.elements.filter(
|
|
||||||
(el) => el.type === "ellipse" && el.descriptions.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
ellipsesWithDesc.forEach((ellipse) => {
|
|
||||||
// Calculate average radius
|
|
||||||
const rx = parseFloat(ellipse.properties.rx || "0");
|
|
||||||
const ry = parseFloat(ellipse.properties.ry || "0");
|
|
||||||
const avgRadius = (rx + ry) / 2;
|
|
||||||
|
|
||||||
context.warnings.push({
|
|
||||||
code: "ELLIPSE_WITH_DESC",
|
|
||||||
message: `Ellipse (id=${ellipse.properties.id ||
|
|
||||||
"no-id"}) has a description. It will be processed as a circle with radius ${avgRadius} (average of rx=${rx} and ry=${ry}).`,
|
|
||||||
element: ellipse,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: Check that hyperlinks starting with "anchor" point to existing anchors and all anchors are referenced
|
|
||||||
function lintHyperlinkAnchors(context: LintContext): void {
|
|
||||||
// Find all anchor elements (rect elements with IDs starting with "anchor")
|
|
||||||
const anchorElements = context.elements.filter(
|
|
||||||
(el) => el.type === "rect" && el.properties.id?.startsWith("anchor")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find all hyperlinks (a elements)
|
|
||||||
const aElements = Array.from(context.svgDoc.getElementsByTagName("a"));
|
|
||||||
|
|
||||||
// Track anchors that have been referenced
|
|
||||||
const referencedAnchors = new Set<string>();
|
|
||||||
|
|
||||||
// Check all hyperlinks for valid anchor references
|
|
||||||
aElements.forEach((aElement) => {
|
|
||||||
const href = aElement.getAttribute("xlink:href");
|
|
||||||
if (!href) return;
|
|
||||||
|
|
||||||
if (href.startsWith("anchor")) {
|
|
||||||
// This is an anchor reference - check if the anchor exists
|
|
||||||
const anchorId = href.startsWith("#") ? href.substring(1) : href;
|
|
||||||
const targetAnchor = anchorElements.find(
|
|
||||||
(el) => el.properties.id === anchorId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!targetAnchor) {
|
|
||||||
// Hyperlink points to a non-existent anchor
|
|
||||||
context.warnings.push({
|
|
||||||
code: "BROKEN_ANCHOR_LINK",
|
|
||||||
message: `Hyperlink points to non-existent anchor: ${href}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Mark this anchor as referenced
|
|
||||||
referencedAnchors.add(anchorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for unreferenced anchors
|
|
||||||
anchorElements.forEach((anchor) => {
|
|
||||||
if (!referencedAnchors.has(anchor.properties.id)) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "UNREFERENCED_ANCHOR",
|
|
||||||
message: `Anchor element with id '${anchor.properties.id}' is not referenced by any hyperlink.`,
|
|
||||||
element: anchor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: Validate that image elements with descriptions follow the proper format for video scrolls
|
|
||||||
function lintVideoScrollPaths(context: LintContext): void {
|
|
||||||
const imagesWithDesc = context.elements.filter(
|
|
||||||
(el) => el.type === "image" && el.descriptions.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
imagesWithDesc.forEach((image) => {
|
|
||||||
const desc = image.descriptions[0];
|
|
||||||
const descLines = desc.split("\n").filter((line) => line.trim().length > 0);
|
|
||||||
|
|
||||||
// Check that the description has at least 2 lines
|
|
||||||
if (descLines.length < 2) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "INVALID_SCROLL_DESC_FORMAT",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) has a description that doesn't follow the required format: first line for direction, second line for file path.`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First line should be a valid direction
|
|
||||||
const directionLine = descLines[0];
|
|
||||||
const validDirections = [
|
|
||||||
"up",
|
|
||||||
"down",
|
|
||||||
"left",
|
|
||||||
"right",
|
|
||||||
"up left",
|
|
||||||
"up right",
|
|
||||||
"down left",
|
|
||||||
"down right",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check if any valid direction is in the direction line
|
|
||||||
const hasValidDirection = validDirections.some((dir) =>
|
|
||||||
directionLine.toLowerCase().includes(dir)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasValidDirection) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "INVALID_SCROLL_DIRECTION",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) has a description with invalid scroll direction: "${directionLine}". Valid directions: ${validDirections.join(
|
|
||||||
", "
|
|
||||||
)}.`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second line should point to an existing file
|
|
||||||
const filePath = descLines[1].replace(/^\//, "");
|
|
||||||
try {
|
|
||||||
const fullPath = path.resolve(context.fileDir, filePath);
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "SCROLL_FILE_MISSING",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) references a non-existent file in description: ${filePath}`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Path resolution error
|
|
||||||
context.warnings.push({
|
|
||||||
code: "SCROLL_PATH_ERROR",
|
|
||||||
message: `Error checking path for image (id=${image.properties.id ||
|
|
||||||
"no-id"}): ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check if xlink:href exists and points to a valid directory
|
|
||||||
const xlinkHref = image.properties["xlink:href"];
|
|
||||||
if (!xlinkHref) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "SCROLL_MISSING_HREF",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) with scroll description is missing xlink:href attribute`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
} else if (!xlinkHref.includes("/")) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "INVALID_SCROLL_PATH",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) has an xlink:href that doesn't point to a directory: ${xlinkHref}`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: Check that all element IDs are unique across the SVG
|
|
||||||
function lintIdUniqueness(context: LintContext): void {
|
|
||||||
// Create a map to track IDs and their elements
|
|
||||||
const idMap = new Map<string, ElementInfo[]>();
|
|
||||||
|
|
||||||
// Collect all elements with IDs
|
|
||||||
context.elements.forEach((element) => {
|
|
||||||
if (element.properties.id) {
|
|
||||||
const elementsWithId = idMap.get(element.properties.id) || [];
|
|
||||||
elementsWithId.push(element);
|
|
||||||
idMap.set(element.properties.id, elementsWithId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for duplicates
|
|
||||||
idMap.forEach((elements, id) => {
|
|
||||||
if (elements.length > 1) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "DUPLICATE_ID",
|
|
||||||
message: `ID '${id}' is used by ${
|
|
||||||
elements.length
|
|
||||||
} elements: ${elements.map((e) => e.type).join(", ")}.`,
|
|
||||||
element: elements[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lint: Check that files referenced in videoscroll file lists exist
|
|
||||||
function lintVideoScrollFileContents(context: LintContext): void {
|
|
||||||
// Find all image elements with descriptions that follow the videoscroll format
|
|
||||||
const imagesWithDesc = context.elements.filter(
|
|
||||||
(el) => el.type === "image" && el.descriptions.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process only images with valid descriptions (at least 2 lines)
|
|
||||||
imagesWithDesc.forEach((image) => {
|
|
||||||
const desc = image.descriptions[0];
|
|
||||||
const descLines = desc.split("\n").filter((line) => line.trim().length > 0);
|
|
||||||
|
|
||||||
// Skip if the description doesn't have at least 2 lines
|
|
||||||
if (descLines.length < 2) return;
|
|
||||||
|
|
||||||
// Get the file list path from the second line
|
|
||||||
const fileListPath = descLines[1].replace(/^\//, "");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Resolve the full path to the file list
|
|
||||||
const fullFileListPath = path.resolve(context.fileDir, fileListPath);
|
|
||||||
|
|
||||||
// Skip if the file list doesn't exist (already checked in lintVideoScrollPaths)
|
|
||||||
if (!fs.existsSync(fullFileListPath)) return;
|
|
||||||
|
|
||||||
// Read the file list contents
|
|
||||||
const fileListContent = fs.readFileSync(fullFileListPath, "utf-8");
|
|
||||||
const referencedFiles = fileListContent
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
|
|
||||||
// Get the directory containing the file list
|
|
||||||
const fileListDir = path.dirname(fullFileListPath);
|
|
||||||
|
|
||||||
// Check if each referenced file exists
|
|
||||||
const missingFiles: string[] = [];
|
|
||||||
|
|
||||||
referencedFiles.forEach((referencedFile) => {
|
|
||||||
const fullFilePath = path.resolve(fileListDir, referencedFile);
|
|
||||||
if (!fs.existsSync(fullFilePath)) {
|
|
||||||
missingFiles.push(referencedFile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there are missing files, emit a warning
|
|
||||||
if (missingFiles.length > 0) {
|
|
||||||
context.warnings.push({
|
|
||||||
code: "SCROLL_MISSING_FILES",
|
|
||||||
message: `Image (id=${image.properties.id ||
|
|
||||||
"no-id"}) references a file list (${fileListPath}) that contains ${
|
|
||||||
missingFiles.length
|
|
||||||
} missing files: ${missingFiles.join(", ")}.`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Error reading or parsing the file list
|
|
||||||
context.warnings.push({
|
|
||||||
code: "SCROLL_FILE_LIST_ERROR",
|
|
||||||
message: `Error reading file list for image (id=${image.properties.id ||
|
|
||||||
"no-id"}): ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
element: image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run all lint checks
|
|
||||||
function runAllLints(context: LintContext): void {
|
|
||||||
lintStartRect(context);
|
|
||||||
lintCircleDescriptions(context);
|
|
||||||
lintEllipseDescriptions(context);
|
|
||||||
lintHyperlinkAnchors(context);
|
|
||||||
lintVideoScrollPaths(context);
|
|
||||||
lintIdUniqueness(context);
|
|
||||||
lintVideoScrollFileContents(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output warnings
|
|
||||||
function outputWarnings(context: LintContext): void {
|
|
||||||
if (context.warnings.length === 0) {
|
|
||||||
console.log("✅ SVG file passes all lints.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n⚠️ Lint Warnings:");
|
|
||||||
console.log("=================\n");
|
|
||||||
|
|
||||||
context.warnings.forEach((warning, index) => {
|
|
||||||
console.log(`${index + 1}. [${warning.code}] ${warning.message}`);
|
|
||||||
if (warning.element) {
|
|
||||||
console.log(
|
|
||||||
` Element: ${warning.element.type}${
|
|
||||||
warning.element.properties.id
|
|
||||||
? ` (id=${warning.element.properties.id})`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Total warnings: ${context.warnings.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output inspect details
|
|
||||||
function outputInspectDetails(elements: ElementInfo[]): void {
|
|
||||||
console.log("\nElements found in the SVG:");
|
|
||||||
console.log("========================\n");
|
|
||||||
|
|
||||||
if (elements.length === 0) {
|
|
||||||
console.log(
|
|
||||||
"No matching elements (image, rect, ellipse, circle) found in the SVG."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.forEach((element, index) => {
|
|
||||||
console.log(`${index + 1}. Type: ${element.type}`);
|
|
||||||
console.log(" Properties:");
|
|
||||||
Object.entries(element.properties).forEach(([name, value]) => {
|
|
||||||
if (value.length > 128) {
|
|
||||||
console.log(` - ${name}: ${value.substring(0, 128)}...`);
|
|
||||||
} else {
|
|
||||||
console.log(` - ${name}: ${value}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Output descriptions if any exist
|
|
||||||
if (element.descriptions.length > 0) {
|
|
||||||
console.log(" Descriptions:");
|
|
||||||
element.descriptions.forEach((desc, descIndex) => {
|
|
||||||
if (desc.length > 128) {
|
|
||||||
console.log(` - ${descIndex + 1}: ${desc.substring(0, 128)}...`);
|
|
||||||
} else {
|
|
||||||
console.log(` - ${descIndex + 1}: ${desc}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Total elements found: ${elements.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to process the SVG file
|
|
||||||
*/
|
|
||||||
function main() {
|
|
||||||
const options = program.opts();
|
|
||||||
const file = program.args[0];
|
|
||||||
console.log(`Processing SVG file: ${file}`);
|
|
||||||
|
|
||||||
const svgDoc = parseSvgFile(file);
|
|
||||||
const fileDir = path.dirname(path.resolve(file));
|
|
||||||
|
|
||||||
// Extract elements of interest
|
|
||||||
const elementTypes = ["image", "rect", "ellipse", "circle"];
|
|
||||||
const allElements: ElementInfo[] = [];
|
|
||||||
|
|
||||||
elementTypes.forEach((type) => {
|
|
||||||
const elements = extractElements(svgDoc, type);
|
|
||||||
allElements.push(...elements);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create lint context
|
|
||||||
const context: LintContext = {
|
|
||||||
filePath: file,
|
|
||||||
fileDir: fileDir,
|
|
||||||
svgDoc: svgDoc,
|
|
||||||
elements: allElements,
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run all lint checks
|
|
||||||
runAllLints(context);
|
|
||||||
|
|
||||||
// Output inspection details if requested
|
|
||||||
if (options.inspect) {
|
|
||||||
outputInspectDetails(allElements);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output warnings
|
|
||||||
outputWarnings(context);
|
|
||||||
|
|
||||||
// Exit with appropriate code
|
|
||||||
if (context.warnings.length > 0) {
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the main function
|
|
||||||
main();
|
|
||||||
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "tmp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||||
|
"@tsconfig/svelte": "^3.0.0",
|
||||||
|
"@types/stats.js": "^0.17.0",
|
||||||
|
"svelte": "^3.49.0",
|
||||||
|
"svelte-check": "^2.8.0",
|
||||||
|
"svelte-preprocess": "^4.10.7",
|
||||||
|
"tslib": "^2.4.0",
|
||||||
|
"typescript": "^4.6.4",
|
||||||
|
"vite": "^3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/browser": "^7.7.0",
|
||||||
|
"@sentry/tracing": "^7.7.0",
|
||||||
|
"fetch-progress": "^1.3.0",
|
||||||
|
"normalize.css": "^8.0.1",
|
||||||
|
"panzoom": "^9.4.3",
|
||||||
|
"sass": "^1.54.0",
|
||||||
|
"stats.js": "^0.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# 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" ]
|
|
||||||
31
src/App.svelte
Normal file
31
src/App.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import SvgContent from "./components/SVGContent.svelte";
|
||||||
|
|
||||||
|
function setBackground(ev: CustomEvent<string>) {
|
||||||
|
document.body.style.background = ev.detail;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<SvgContent url="content/intro.svg" on:setBackground={setBackground} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app,
|
||||||
|
main,
|
||||||
|
main > * {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
use super::LasApp;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(super) struct ViewAnimation {
|
|
||||||
start_center_x: f32,
|
|
||||||
start_center_y: f32,
|
|
||||||
start_zoom: f32,
|
|
||||||
target_center_x: f32,
|
|
||||||
target_center_y: f32,
|
|
||||||
target_zoom: f32,
|
|
||||||
elapsed: f32,
|
|
||||||
duration: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LasApp {
|
|
||||||
pub(super) fn cancel_animation(&mut self) {
|
|
||||||
self.view_animation = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn begin_view_animation(
|
|
||||||
&mut self,
|
|
||||||
target_pan_x: f32,
|
|
||||||
target_pan_y: f32,
|
|
||||||
target_zoom: f32,
|
|
||||||
duration: f32,
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
) {
|
|
||||||
if duration <= 0.0 {
|
|
||||||
self.pan_x = target_pan_x;
|
|
||||||
self.pan_y = target_pan_y;
|
|
||||||
self.zoom = target_zoom;
|
|
||||||
self.view_animation = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (start_center_x, start_center_y) =
|
|
||||||
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
|
|
||||||
let (target_center_x, target_center_y) =
|
|
||||||
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
|
|
||||||
|
|
||||||
self.view_animation = Some(ViewAnimation {
|
|
||||||
start_center_x,
|
|
||||||
start_center_y,
|
|
||||||
start_zoom: self.zoom,
|
|
||||||
target_center_x,
|
|
||||||
target_center_y,
|
|
||||||
target_zoom,
|
|
||||||
elapsed: 0.0,
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
|
|
||||||
if let Some(anim) = &mut self.view_animation {
|
|
||||||
anim.elapsed += dt;
|
|
||||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
|
||||||
let eased = ease_in_out_cubic(t);
|
|
||||||
|
|
||||||
let zoom = lerp(
|
|
||||||
anim.start_zoom,
|
|
||||||
anim.target_zoom,
|
|
||||||
zoom_ease(t, anim.start_zoom, anim.target_zoom),
|
|
||||||
);
|
|
||||||
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
|
|
||||||
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
|
|
||||||
|
|
||||||
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
|
|
||||||
let visible_w = canvas_rect.width() / zoom;
|
|
||||||
let visible_h = canvas_rect.height() / zoom;
|
|
||||||
self.pan_x = -center_x + visible_w * 0.5;
|
|
||||||
self.pan_y = -center_y + visible_h * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.zoom = zoom;
|
|
||||||
|
|
||||||
if t >= 1.0 {
|
|
||||||
self.view_animation = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn request_view_to_rect(
|
|
||||||
&mut self,
|
|
||||||
rect: (f32, f32, f32, f32),
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
duration: f32,
|
|
||||||
) {
|
|
||||||
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
|
|
||||||
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle zoom towards a specific point.
|
|
||||||
pub(super) fn zoom_towards(
|
|
||||||
&mut self,
|
|
||||||
new_zoom: f32,
|
|
||||||
pos: egui::Pos2,
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ease_in_out_cubic(t: f32) -> f32 {
|
|
||||||
if t < 0.5 {
|
|
||||||
4.0 * t * t * t
|
|
||||||
} else {
|
|
||||||
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ease_out_cubic(t: f32) -> f32 {
|
|
||||||
1.0 - (1.0 - t).powi(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
|
|
||||||
if target > start {
|
|
||||||
// Zooming in: bias progress late but soften landing
|
|
||||||
let biased = t.powf(1.6);
|
|
||||||
ease_in_out_cubic(biased)
|
|
||||||
} else {
|
|
||||||
// Zooming out: shed zoom early
|
|
||||||
ease_out_cubic(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
|
||||||
a + (b - a) * t
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn compute_view_for_rect(
|
|
||||||
rect: (f32, f32, f32, f32),
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
) -> Option<(f32, f32, f32)> {
|
|
||||||
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (sx, sy, sw, sh) = rect;
|
|
||||||
if sw <= 0.0 || sh <= 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scale_x = canvas_rect.width() / sw;
|
|
||||||
let scale_y = canvas_rect.height() / sh;
|
|
||||||
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
|
||||||
let visible_w = canvas_rect.width() / fit_zoom;
|
|
||||||
let visible_h = canvas_rect.height() / fit_zoom;
|
|
||||||
|
|
||||||
let pan_x = -sx + (visible_w - sw) * 0.5;
|
|
||||||
let pan_y = -sy + (visible_h - sh) * 0.5;
|
|
||||||
|
|
||||||
Some((pan_x, pan_y, fit_zoom))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
|
|
||||||
let visible_w = canvas_rect.width() / zoom;
|
|
||||||
let visible_h = canvas_rect.height() / zoom;
|
|
||||||
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct InputSnapshot {
|
|
||||||
pub(super) escape_pressed: bool,
|
|
||||||
pub(super) f3_pressed: bool,
|
|
||||||
pub(super) f_pressed: bool,
|
|
||||||
pub(super) scroll_delta: f32,
|
|
||||||
pub(super) zoom_delta: f32,
|
|
||||||
pub(super) pointer_latest: Option<egui::Pos2>,
|
|
||||||
pub(super) pointer_pos: Option<egui::Pos2>,
|
|
||||||
pub(super) frame_time: f32,
|
|
||||||
pub(super) primary_down: bool,
|
|
||||||
pub(super) primary_pressed: bool,
|
|
||||||
pub(super) primary_released: bool,
|
|
||||||
pub(super) space_pressed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputSnapshot {
|
|
||||||
pub(super) fn collect(ctx: &egui::Context) -> Self {
|
|
||||||
ctx.input(|i| Self {
|
|
||||||
escape_pressed: i.key_pressed(egui::Key::Escape),
|
|
||||||
f3_pressed: i.key_pressed(egui::Key::F3),
|
|
||||||
f_pressed: i.key_pressed(egui::Key::F),
|
|
||||||
scroll_delta: i.smooth_scroll_delta.y,
|
|
||||||
zoom_delta: i.zoom_delta(),
|
|
||||||
pointer_latest: i.pointer.latest_pos(),
|
|
||||||
pointer_pos: i.pointer.hover_pos(),
|
|
||||||
frame_time: i.stable_dt,
|
|
||||||
primary_down: i.pointer.primary_down(),
|
|
||||||
primary_pressed: i.pointer.primary_pressed(),
|
|
||||||
primary_released: i.pointer.primary_released(),
|
|
||||||
space_pressed: i.key_pressed(egui::Key::Space),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
303
src/app/mod.rs
303
src/app/mod.rs
|
|
@ -1,303 +0,0 @@
|
||||||
use crate::svg::SvgContent;
|
|
||||||
use crate::text_cache::TextCache;
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use std::cell::RefCell;
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
mod animation;
|
|
||||||
mod input;
|
|
||||||
mod render;
|
|
||||||
|
|
||||||
use input::InputSnapshot;
|
|
||||||
|
|
||||||
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct LasApp {
|
|
||||||
#[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,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
render_internal_areas: bool,
|
|
||||||
|
|
||||||
/// Exponential moving average of frame time for stable FPS display.
|
|
||||||
#[serde(skip)]
|
|
||||||
fps_ema: f32,
|
|
||||||
|
|
||||||
/// Whether we've auto-fitted the start viewport.
|
|
||||||
#[serde(skip)]
|
|
||||||
did_fit_start: bool,
|
|
||||||
|
|
||||||
/// Last known cursor position (for edge scrolling even without movement).
|
|
||||||
#[serde(skip)]
|
|
||||||
last_cursor_pos: Option<egui::Pos2>,
|
|
||||||
|
|
||||||
/// Whether a reset-to-start was requested (from UI)
|
|
||||||
#[serde(skip)]
|
|
||||||
reset_view_requested: bool,
|
|
||||||
|
|
||||||
/// Smooth camera animation state.
|
|
||||||
#[serde(skip)]
|
|
||||||
view_animation: Option<animation::ViewAnimation>,
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
|
|
||||||
/// Text rendering cache for smooth scaling.
|
|
||||||
#[serde(skip)]
|
|
||||||
text_cache: Option<TextCache>,
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
#[serde(skip)]
|
|
||||||
svg_loader: Option<Rc<RefCell<Option<Result<SvgContent, String>>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LasApp {
|
|
||||||
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,
|
|
||||||
render_internal_areas: false,
|
|
||||||
fps_ema: 60.0,
|
|
||||||
last_pointer_pos: None,
|
|
||||||
is_dragging: false,
|
|
||||||
text_cache: None,
|
|
||||||
did_fit_start: false,
|
|
||||||
last_cursor_pos: None,
|
|
||||||
reset_view_requested: false,
|
|
||||||
view_animation: None,
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
svg_loader: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LasApp {
|
|
||||||
const NAV_DURATION: f32 = 1.5;
|
|
||||||
|
|
||||||
/// Called once before the first frame.
|
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
||||||
log::info!("Initializing application...");
|
|
||||||
|
|
||||||
log::debug!("Installing image loaders...");
|
|
||||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
|
||||||
|
|
||||||
log::debug!("Loading app state...");
|
|
||||||
let mut app: Self = cc
|
|
||||||
.storage
|
|
||||||
.and_then(|s| eframe::get_value(s, eframe::APP_KEY))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
// Load SVG content
|
|
||||||
let svg_path = "../line-and-surface/content/intro.svg";
|
|
||||||
log::info!("Loading SVG from: {svg_path}");
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
match SvgContent::from_file(svg_path) {
|
|
||||||
Ok(content) => {
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
log::info!(
|
|
||||||
"Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
|
||||||
elapsed,
|
|
||||||
content.video_scrolls.len(),
|
|
||||||
content.audio_areas.len(),
|
|
||||||
content.anchors.len(),
|
|
||||||
content.texts.len()
|
|
||||||
);
|
|
||||||
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
|
|
||||||
log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})");
|
|
||||||
app.pan_x = -vb_x;
|
|
||||||
app.pan_y = -vb_y;
|
|
||||||
}
|
|
||||||
app.svg_content = Some(content);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load SVG: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
let svg_path = "./content/intro.svg";
|
|
||||||
log::info!("Loading SVG from: {svg_path}");
|
|
||||||
let loader = Rc::new(RefCell::new(None));
|
|
||||||
let loader_handle = loader.clone();
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let start = wasm_now();
|
|
||||||
let result = load_svg_from_url(svg_path).await;
|
|
||||||
if let Ok(ref content) = result {
|
|
||||||
let elapsed_ms = (wasm_now() - start).max(0.0);
|
|
||||||
log::info!(
|
|
||||||
"Loaded SVG in {:.2}ms: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
|
||||||
elapsed_ms,
|
|
||||||
content.video_scrolls.len(),
|
|
||||||
content.audio_areas.len(),
|
|
||||||
content.anchors.len(),
|
|
||||||
content.texts.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*loader_handle.borrow_mut() = Some(result);
|
|
||||||
});
|
|
||||||
app.svg_loader = Some(loader);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Application initialized");
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_fps(&mut self, frame_time: f32) {
|
|
||||||
if frame_time > 0.0 {
|
|
||||||
const ALPHA: f32 = 0.1;
|
|
||||||
self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn poll_svg_loader(&mut self, ctx: &egui::Context) {
|
|
||||||
if self.svg_content.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(loader) = self.svg_loader.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(result) = loader.borrow_mut().take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(content) => {
|
|
||||||
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
|
|
||||||
log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})");
|
|
||||||
self.pan_x = -vb_x;
|
|
||||||
self.pan_y = -vb_y;
|
|
||||||
}
|
|
||||||
self.svg_content = Some(content);
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::error!("Failed to load SVG: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.svg_loader = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for LasApp {
|
|
||||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
|
||||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
self.poll_svg_loader(ctx);
|
|
||||||
|
|
||||||
let input = InputSnapshot::collect(ctx);
|
|
||||||
|
|
||||||
if input.primary_pressed
|
|
||||||
|| input.scroll_delta != 0.0
|
|
||||||
|| (input.zoom_delta - 1.0).abs() > f32::EPSILON
|
|
||||||
{
|
|
||||||
self.cancel_animation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pos) = input.pointer_latest.or(input.pointer_pos) {
|
|
||||||
self.last_cursor_pos = Some(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
|
||||||
|
|
||||||
self.update_fps(input.frame_time);
|
|
||||||
|
|
||||||
if input.escape_pressed {
|
|
||||||
self.show_menu_bar = !self.show_menu_bar;
|
|
||||||
}
|
|
||||||
if input.f3_pressed {
|
|
||||||
self.show_debug = !self.show_debug;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.show_menu_bar {
|
|
||||||
self.render_menu_bar(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rendered_count = egui::CentralPanel::default()
|
|
||||||
.show(ctx, |ui| self.render_canvas(ctx, ui, &input, is_fullscreen))
|
|
||||||
.inner;
|
|
||||||
|
|
||||||
if self.show_debug {
|
|
||||||
self.render_debug_window(ctx, rendered_count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
async fn load_svg_from_url(url: &str) -> Result<SvgContent, String> {
|
|
||||||
use wasm_bindgen_futures::JsFuture;
|
|
||||||
use web_sys::Response;
|
|
||||||
use web_sys::wasm_bindgen::JsCast;
|
|
||||||
|
|
||||||
let window = web_sys::window().ok_or("missing window".to_string())?;
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_str(url))
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("fetch failed: {err:?}"))?;
|
|
||||||
let response: Response = resp_value
|
|
||||||
.dyn_into()
|
|
||||||
.map_err(|_| "fetch response cast failed".to_string())?;
|
|
||||||
|
|
||||||
if !response.ok() {
|
|
||||||
return Err(format!(
|
|
||||||
"fetch failed: {} {}",
|
|
||||||
response.status(),
|
|
||||||
response.status_text()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let text_promise = response
|
|
||||||
.text()
|
|
||||||
.map_err(|err| format!("read text failed: {err:?}"))?;
|
|
||||||
let text_value = JsFuture::from(text_promise)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("read text failed: {err:?}"))?;
|
|
||||||
let text = text_value
|
|
||||||
.as_string()
|
|
||||||
.ok_or("response text missing".to_string())?;
|
|
||||||
|
|
||||||
SvgContent::parse(&text).map_err(|err| format!("parse SVG failed: {err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn wasm_now() -> f64 {
|
|
||||||
web_sys::window()
|
|
||||||
.and_then(|window| window.performance())
|
|
||||||
.map(|performance| performance.now())
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
use super::LasApp;
|
|
||||||
use super::animation::compute_view_for_rect;
|
|
||||||
use super::input::InputSnapshot;
|
|
||||||
use crate::svg::Renderable as _;
|
|
||||||
|
|
||||||
impl LasApp {
|
|
||||||
pub(super) fn render_menu_bar(&mut self, ctx: &egui::Context) {
|
|
||||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
|
||||||
egui::MenuBar::new().ui(ui, |ui| {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
|
|
||||||
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
|
||||||
if ui.button("Reset View").clicked() {
|
|
||||||
self.reset_view_requested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
let debug_label = if self.show_debug {
|
|
||||||
"Hide Debug (F3)"
|
|
||||||
} else {
|
|
||||||
"Show Debug (F3)"
|
|
||||||
};
|
|
||||||
if ui.button(debug_label).clicked() {
|
|
||||||
self.show_debug = !self.show_debug;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn render_debug_window(&mut self, ctx: &egui::Context, rendered_count: u32) {
|
|
||||||
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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.checkbox(&mut self.render_internal_areas, "Render internal areas");
|
|
||||||
|
|
||||||
if let Some(ref cache) = self.text_cache {
|
|
||||||
ui.separator();
|
|
||||||
let bytes = cache.cache_memory_bytes();
|
|
||||||
let mb = bytes as f32 / (1024.0 * 1024.0);
|
|
||||||
ui.label(format!(
|
|
||||||
"Text cache: {} entries (~{mb:.2} MB)",
|
|
||||||
cache.cache_size()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(clippy::too_many_lines)]
|
|
||||||
pub(super) fn render_canvas(
|
|
||||||
&mut self,
|
|
||||||
ctx: &egui::Context,
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
input: &InputSnapshot,
|
|
||||||
is_fullscreen: bool,
|
|
||||||
) -> u32 {
|
|
||||||
let (response, painter) =
|
|
||||||
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
|
||||||
let canvas_rect = response.rect;
|
|
||||||
|
|
||||||
let background_rgb = self
|
|
||||||
.svg_content
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|content| content.background_color)
|
|
||||||
.unwrap_or([0, 0, 0]);
|
|
||||||
let background_color =
|
|
||||||
egui::Color32::from_rgb(background_rgb[0], background_rgb[1], background_rgb[2]);
|
|
||||||
painter.rect_filled(canvas_rect, 0.0, background_color);
|
|
||||||
|
|
||||||
let anim_dt = if input.frame_time > 0.0 {
|
|
||||||
input.frame_time
|
|
||||||
} else {
|
|
||||||
1.0 / 60.0
|
|
||||||
};
|
|
||||||
self.update_animation(anim_dt, &canvas_rect);
|
|
||||||
if self.view_animation.is_some() {
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.double_clicked() || input.f_pressed {
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.did_fit_start {
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
|
||||||
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, &canvas_rect) {
|
|
||||||
self.pan_x = pan_x;
|
|
||||||
self.pan_y = pan_y;
|
|
||||||
self.zoom = zoom;
|
|
||||||
}
|
|
||||||
self.did_fit_start = true;
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.space_pressed {
|
|
||||||
self.reset_view_requested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.reset_view_requested {
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
|
||||||
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reset_view_requested = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.primary_pressed && response.hovered() {
|
|
||||||
self.is_dragging = true;
|
|
||||||
self.last_pointer_pos = input.pointer_pos;
|
|
||||||
}
|
|
||||||
if input.primary_released {
|
|
||||||
self.is_dragging = false;
|
|
||||||
self.last_pointer_pos = None;
|
|
||||||
}
|
|
||||||
if self.is_dragging && input.primary_down {
|
|
||||||
if let (Some(current), Some(last)) = (input.pointer_pos, self.last_pointer_pos) {
|
|
||||||
let delta = current - last;
|
|
||||||
self.pan_x += delta.x / self.zoom;
|
|
||||||
self.pan_y += delta.y / self.zoom;
|
|
||||||
}
|
|
||||||
self.last_pointer_pos = input.pointer_pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.hovered() {
|
|
||||||
if input.scroll_delta != 0.0 {
|
|
||||||
let factor = 1.0 + input.scroll_delta * 0.001;
|
|
||||||
let new_zoom = (self.zoom * factor).clamp(0.01, 100.0);
|
|
||||||
if let Some(pos) = input.pointer_pos {
|
|
||||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
|
||||||
} else {
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if input.zoom_delta != 1.0 {
|
|
||||||
let new_zoom = (self.zoom * input.zoom_delta).clamp(0.01, 100.0);
|
|
||||||
if let Some(pos) = input.pointer_pos {
|
|
||||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
|
||||||
} else {
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let edge_pointer = input.pointer_latest.or(self.last_cursor_pos);
|
|
||||||
if is_fullscreen {
|
|
||||||
if let Some(mouse_pos) = edge_pointer {
|
|
||||||
let dt = if input.frame_time > 0.0 {
|
|
||||||
input.frame_time
|
|
||||||
} else {
|
|
||||||
1.0 / 60.0
|
|
||||||
};
|
|
||||||
let nx = if canvas_rect.width() > 0.0 {
|
|
||||||
((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0)
|
|
||||||
} else {
|
|
||||||
0.5
|
|
||||||
};
|
|
||||||
let ny = if canvas_rect.height() > 0.0 {
|
|
||||||
((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0)
|
|
||||||
} else {
|
|
||||||
0.5
|
|
||||||
};
|
|
||||||
|
|
||||||
let edge_threshold = 0.15;
|
|
||||||
let mut vx = 0.0;
|
|
||||||
let mut vy = 0.0;
|
|
||||||
|
|
||||||
if nx < edge_threshold {
|
|
||||||
vx = (edge_threshold - nx) / edge_threshold;
|
|
||||||
} else if nx > 1.0 - edge_threshold {
|
|
||||||
vx = -(nx - (1.0 - edge_threshold)) / edge_threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ny < edge_threshold {
|
|
||||||
vy = (edge_threshold - ny) / edge_threshold;
|
|
||||||
} else if ny > 1.0 - edge_threshold {
|
|
||||||
vy = -(ny - (1.0 - edge_threshold)) / edge_threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if vx != 0.0 || vy != 0.0 {
|
|
||||||
let view_w = canvas_rect.width() / self.zoom;
|
|
||||||
let view_h = canvas_rect.height() / self.zoom;
|
|
||||||
let speed_w = view_w;
|
|
||||||
let speed_h = view_h;
|
|
||||||
|
|
||||||
self.pan_x += vx * speed_w * dt;
|
|
||||||
self.pan_y += vy * speed_h * dt;
|
|
||||||
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let internal_alpha = (0.3_f32 * 255.0).round() as u8;
|
|
||||||
let video_scroll_color = egui::Color32::from_rgba_unmultiplied(255, 0, 0, internal_alpha);
|
|
||||||
let audio_area_color = egui::Color32::from_rgba_unmultiplied(0, 0, 255, internal_alpha);
|
|
||||||
let anchor_color = egui::Color32::from_rgba_unmultiplied(64, 255, 64, internal_alpha);
|
|
||||||
let text_color = egui::Color32::from_rgb(255, 255, 255);
|
|
||||||
|
|
||||||
if self.text_cache.is_none() {
|
|
||||||
self.text_cache = Some(crate::text_cache::TextCache::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pan_x = self.pan_x;
|
|
||||||
let pan_y = self.pan_y;
|
|
||||||
let zoom = self.zoom;
|
|
||||||
|
|
||||||
let svg_to_screen = |x: f32, y: f32| -> egui::Pos2 {
|
|
||||||
egui::pos2(
|
|
||||||
(x + pan_x) * zoom + canvas_rect.left(),
|
|
||||||
(y + pan_y) * zoom + canvas_rect.top(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let screen_to_svg = |pos: egui::Pos2| -> (f32, f32) {
|
|
||||||
(
|
|
||||||
(pos.x - canvas_rect.left()) / zoom - pan_x,
|
|
||||||
(pos.y - canvas_rect.top()) / zoom - pan_y,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
|
|
||||||
let mut rendered_count = 0u32;
|
|
||||||
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
let pointer_svg_hover = input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
|
||||||
if canvas_rect.contains(p) {
|
|
||||||
Some(screen_to_svg(p))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let click_svg_pos = if input.primary_released && !self.is_dragging {
|
|
||||||
input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
|
||||||
if canvas_rect.contains(p) {
|
|
||||||
Some(screen_to_svg(p))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = click_svg_pos {
|
|
||||||
if let Some(link) = content.anchor_links.iter().find(|link| {
|
|
||||||
svg_x >= link.x
|
|
||||||
&& svg_x <= link.x + link.width
|
|
||||||
&& svg_y >= link.y
|
|
||||||
&& svg_y <= link.y + link.height
|
|
||||||
}) {
|
|
||||||
if let Some(anchor) = content.anchors.iter().find(|a| a.id == link.target_id) {
|
|
||||||
pending_navigation = Some(anchor.bounds());
|
|
||||||
} else {
|
|
||||||
log::warn!("Anchor link target not found: {}", link.target_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg_hover {
|
|
||||||
if content.anchor_links.iter().any(|link| {
|
|
||||||
svg_x >= link.x
|
|
||||||
&& svg_x <= link.x + link.width
|
|
||||||
&& svg_y >= link.y
|
|
||||||
&& svg_y <= link.y + link.height
|
|
||||||
}) {
|
|
||||||
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.render_internal_areas {
|
|
||||||
let mut hovered_descs: Vec<String> = Vec::new();
|
|
||||||
let pointer_svg = input.pointer_pos.map(screen_to_svg);
|
|
||||||
|
|
||||||
for vs in &content.video_scrolls {
|
|
||||||
let (x, y, w, h) = vs.bounds();
|
|
||||||
let rect =
|
|
||||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.rect_filled(rect, 0.0, video_scroll_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
|
||||||
hovered_descs.push(format!("Video: {}", vs.desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for aa in &content.audio_areas {
|
|
||||||
let center = svg_to_screen(aa.cx, aa.cy);
|
|
||||||
let radius = aa.radius * zoom;
|
|
||||||
let rect = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(center.x - radius, center.y - radius),
|
|
||||||
egui::pos2(center.x + radius, center.y + radius),
|
|
||||||
);
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.circle_filled(center, radius, audio_area_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
let dx = svg_x - aa.cx;
|
|
||||||
let dy = svg_y - aa.cy;
|
|
||||||
if dx * dx + dy * dy <= aa.radius * aa.radius {
|
|
||||||
hovered_descs.push(format!("Audio: {}", aa.desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for anchor in &content.anchors {
|
|
||||||
let (x, y, w, h) = anchor.bounds();
|
|
||||||
let rect =
|
|
||||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.rect_filled(rect, 0.0, anchor_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
|
||||||
hovered_descs.push(format!("Anchor: {}", anchor.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hovered_descs.is_empty() {
|
|
||||||
egui::Tooltip::always_open(
|
|
||||||
ctx.clone(),
|
|
||||||
ui.layer_id(),
|
|
||||||
egui::Id::new("internal-area-desc"),
|
|
||||||
egui::PopupAnchor::Pointer,
|
|
||||||
)
|
|
||||||
.gap(12.0)
|
|
||||||
.show(|ui| {
|
|
||||||
for desc in hovered_descs {
|
|
||||||
ui.label(desc);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
|
||||||
for text_elem in &content.texts {
|
|
||||||
let (x, y, w, h) = text_elem.bounds();
|
|
||||||
let rect =
|
|
||||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
|
||||||
|
|
||||||
if !rect.intersects(canvas_rect) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let display_font_size = text_elem.font_size * zoom;
|
|
||||||
if display_font_size >= 0.5 {
|
|
||||||
for line in &text_elem.lines {
|
|
||||||
if line.content.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached =
|
|
||||||
text_cache.get_or_create(ctx, &line.content, text_elem.font_size, zoom);
|
|
||||||
let scale_factor = zoom / cached.render_scale;
|
|
||||||
let display_width = cached.width as f32 * scale_factor;
|
|
||||||
let display_height = cached.height as f32 * scale_factor;
|
|
||||||
|
|
||||||
let y_adjusted = line.y - text_elem.font_size * 0.8;
|
|
||||||
let pos = svg_to_screen(line.x, y_adjusted);
|
|
||||||
|
|
||||||
painter.image(
|
|
||||||
cached.texture.id(),
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
pos,
|
|
||||||
egui::vec2(display_width, display_height),
|
|
||||||
),
|
|
||||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
||||||
text_color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rect) = pending_navigation {
|
|
||||||
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
src/components/AudioArea.svelte
Normal file
66
src/components/AudioArea.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface AudioAreaDef {
|
||||||
|
id: string;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
radius: number;
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { BoundingBox } from "./SVGContent.svelte";
|
||||||
|
|
||||||
|
export let definition: AudioAreaDef;
|
||||||
|
export let bbox: BoundingBox;
|
||||||
|
|
||||||
|
let audio: HTMLAudioElement;
|
||||||
|
|
||||||
|
console.debug(`[AUDIOAREA] Initializing ${definition.src}...`);
|
||||||
|
// console.debug({definition});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
function onBBoxChange() {
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const x = bbox.x + bbox.w / 2;
|
||||||
|
const y = bbox.y + bbox.h / 2;
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(x - definition.cx, 2) + Math.pow(y - definition.cy, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance < definition.radius) {
|
||||||
|
if (audio.paused) {
|
||||||
|
console.debug(
|
||||||
|
`[AUDIOAREA] Entered audio area "${definition.src}", starting playback...`
|
||||||
|
);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
const volume = (definition.radius - distance) / definition.radius;
|
||||||
|
audio.volume = volume * (bbox.z < 1 ? bbox.z * vol_x + vol_b : 1);
|
||||||
|
} else {
|
||||||
|
if (!audio.paused) {
|
||||||
|
console.debug(
|
||||||
|
`[AUDIOAREA] Left audio area "${definition.src}", pausing playback...`
|
||||||
|
);
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
bbox.x;
|
||||||
|
bbox.y;
|
||||||
|
bbox.w;
|
||||||
|
bbox.h;
|
||||||
|
bbox.z;
|
||||||
|
onBBoxChange();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<audio bind:this={audio} src={definition.src} loop preload="auto" />
|
||||||
630
src/components/SVGContent.svelte
Normal file
630
src/components/SVGContent.svelte
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
export interface BoundingBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import createPanZoom, { type PanZoom } from "panzoom";
|
||||||
|
import Stats from "stats.js";
|
||||||
|
import { rotate } from "../utils";
|
||||||
|
import fetchProgress from "fetch-progress";
|
||||||
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
import VideoScroll, {
|
||||||
|
VideoScrollDirection,
|
||||||
|
type VideoScrollDef,
|
||||||
|
} from "./VideoScroll.svelte";
|
||||||
|
import AudioArea, { type AudioAreaDef } from "./AudioArea.svelte";
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let url: string;
|
||||||
|
let showInternal = false;
|
||||||
|
$: {
|
||||||
|
if (root) {
|
||||||
|
Array.from(root.getElementsByClassName("internal")).forEach((el) => {
|
||||||
|
(el as SVGElement).style.visibility = showInternal
|
||||||
|
? "visible"
|
||||||
|
: "hidden";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: HTMLDivElement;
|
||||||
|
let anchors: SVGRectElement[] = [];
|
||||||
|
let scrolls = [];
|
||||||
|
let audioAreas = [];
|
||||||
|
let loadedPercent = 0;
|
||||||
|
let panzoom: PanZoom;
|
||||||
|
let panning = false;
|
||||||
|
let bbox: BoundingBox = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 0,
|
||||||
|
h: 0,
|
||||||
|
z: 0,
|
||||||
|
};
|
||||||
|
let mousePosition = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
console.info("[SVG] Initializing.");
|
||||||
|
|
||||||
|
// Fetch & load SVG
|
||||||
|
console.info(`[SVG] Fetching "${url}..."`);
|
||||||
|
const fetchResult = await fetch(url).then(
|
||||||
|
fetchProgress({
|
||||||
|
onProgress(progress) {
|
||||||
|
loadedPercent = (progress.transferred / progress.total) * 100;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!fetchResult.ok) {
|
||||||
|
alert(fetchResult.status);
|
||||||
|
throw new Error("Failed to load.");
|
||||||
|
}
|
||||||
|
const svgParsed = new DOMParser().parseFromString(
|
||||||
|
await fetchResult.text(),
|
||||||
|
"image/svg+xml"
|
||||||
|
) as Document;
|
||||||
|
console.debug("[SVG] Loaded.");
|
||||||
|
loadedPercent = 100;
|
||||||
|
const svg = root.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}`);
|
||||||
|
dispatch("setBackground", pageColor.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanZoom
|
||||||
|
const pz = createPanZoom(root, {
|
||||||
|
smoothScroll: false,
|
||||||
|
minZoom: 0.05,
|
||||||
|
maxZoom: 3637937,
|
||||||
|
zoomSpeed: 0.05,
|
||||||
|
zoomDoubleClickSpeed: 1,
|
||||||
|
beforeMouseDown: () => {
|
||||||
|
return panning;
|
||||||
|
},
|
||||||
|
beforeWheel: () => {
|
||||||
|
return panning;
|
||||||
|
},
|
||||||
|
onDoubleClick: () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
console.debug("[SVG] Fullscreen requested.");
|
||||||
|
document.body.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
console.debug("[SVG] Fullscreen exited.");
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
panzoom = 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 = 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 = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function panToAnchor(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 = processAnchors(svg);
|
||||||
|
console.info(`[SVG] Found ${anchors.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.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 = processAudio(svg);
|
||||||
|
console.info(`[SVG] Found ${audioAreas.length} audio areas.`);
|
||||||
|
|
||||||
|
// Videoscrolls
|
||||||
|
console.debug("[SVG] Processing video scrolls.");
|
||||||
|
scrolls = await processScrolls(svg);
|
||||||
|
console.info(`[SVG] Found ${scrolls.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 && 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 unknown as SVGRectElement | null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="svg-content">
|
||||||
|
<div class="loading-screen" class:loaded={loadedPercent === 100}>
|
||||||
|
<div style="width: {loadedPercent}%" class="loading-bar" />
|
||||||
|
</div>
|
||||||
|
<div class="content" bind:this={root}>
|
||||||
|
<div class="video-scrolls">
|
||||||
|
{#each scrolls as scroll (scroll.id)}
|
||||||
|
<VideoScroll definition={scroll} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each audioAreas as audio (audio.id)}
|
||||||
|
<AudioArea definition={audio} {bbox} />
|
||||||
|
{/each}
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input bind:checked={showInternal} type="checkbox" />
|
||||||
|
Show internal elements
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<!-- svelte-ignore missing-declaration -->
|
||||||
|
<p class="version">
|
||||||
|
Version - {__COMMIT_HASH__}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global {
|
||||||
|
.svg-content svg {
|
||||||
|
overflow: visible;
|
||||||
|
& .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;
|
||||||
|
|
||||||
|
.version {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devpanel div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devpanel label {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devpanel div span {
|
||||||
|
margin: 0 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
src/components/VideoScroll.svelte
Normal file
154
src/components/VideoScroll.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script lang="ts" context="module">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { rotate } from "../utils";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
export let definition: VideoScrollDef;
|
||||||
|
|
||||||
|
let root: HTMLDivElement;
|
||||||
|
let dynamicFiles: { id: string; top: number; left: number; src: string }[] =
|
||||||
|
[];
|
||||||
|
$: {
|
||||||
|
dynamicFiles = definition.files.slice(1).map((src: string, idx: number) => {
|
||||||
|
const id = `${idx}_${src}`;
|
||||||
|
const cy =
|
||||||
|
definition.top +
|
||||||
|
(isVertical ? definition.height * (idx + 1) * verticalDirection : 0);
|
||||||
|
const cx =
|
||||||
|
definition.left +
|
||||||
|
(isHorizontal ? definition.width * (idx + 1) * horizontalDirection : 0);
|
||||||
|
const [left, top] = rotate(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
definition.left,
|
||||||
|
definition.top,
|
||||||
|
definition.angle
|
||||||
|
);
|
||||||
|
return { id, top, left, src };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$: isHorizontal = definition.directions.some(
|
||||||
|
(dir: VideoScrollDirection) =>
|
||||||
|
dir === VideoScrollDirection.LEFT || dir === VideoScrollDirection.RIGHT
|
||||||
|
);
|
||||||
|
$: isVertical = definition.directions.some(
|
||||||
|
(dir: VideoScrollDirection) =>
|
||||||
|
dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
|
||||||
|
);
|
||||||
|
$: horizontalDirection = definition.directions.includes(
|
||||||
|
VideoScrollDirection.RIGHT
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: -1;
|
||||||
|
$: verticalDirection = definition.directions.includes(
|
||||||
|
VideoScrollDirection.DOWN
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const observer = new IntersectionObserver((entries, _) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const element = entry.target as HTMLImageElement;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
element.classList.add("visible");
|
||||||
|
if (!element.src) {
|
||||||
|
console.debug(
|
||||||
|
`[VIDEOSCROLL] Intersected, loading ${element.dataset.src}`
|
||||||
|
);
|
||||||
|
element.src = element.dataset.src!;
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.add("displayed");
|
||||||
|
}, 3000);
|
||||||
|
element.onload = () => {
|
||||||
|
element.classList.add("displayed");
|
||||||
|
element.classList.add("loaded");
|
||||||
|
if (isHorizontal) {
|
||||||
|
element.style.height = "auto";
|
||||||
|
} else {
|
||||||
|
element.style.width = "auto";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
element.classList.remove("visible");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (root) {
|
||||||
|
Array.from(root.children).forEach((el) => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if definition.directions.length > 0}
|
||||||
|
<div class="video-scroll" bind:this={root}>
|
||||||
|
<img
|
||||||
|
class="visible displayed loaded"
|
||||||
|
src={definition.files[0]}
|
||||||
|
style:top="{Math.round(definition.top)}px"
|
||||||
|
style:left="{Math.round(definition.left)}px"
|
||||||
|
style:width={isHorizontal ? `${Math.round(definition.width)}px` : "auto"}
|
||||||
|
style:height={isVertical ? `${Math.round(definition.height)}px` : "auto"}
|
||||||
|
style:transform="rotate({definition.angle}deg)"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#each dynamicFiles as file (file.id)}
|
||||||
|
<img
|
||||||
|
data-src={file.src}
|
||||||
|
style:top="{Math.round(file.top)}px"
|
||||||
|
style:left="{Math.round(file.left)}px"
|
||||||
|
style:width="{Math.round(definition.width)}px"
|
||||||
|
style:height="{Math.round(definition.height)}px"
|
||||||
|
style:transform="rotate({definition.angle}deg)"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.video-scroll img {
|
||||||
|
position: absolute;
|
||||||
|
image-rendering: optimizeSpeed;
|
||||||
|
background: grey;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.displayed {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
#![warn(clippy::all)]
|
|
||||||
|
|
||||||
mod app;
|
|
||||||
pub mod svg;
|
|
||||||
mod text_cache;
|
|
||||||
|
|
||||||
pub use app::LasApp;
|
|
||||||
79
src/main.rs
79
src/main.rs
|
|
@ -1,79 +0,0 @@
|
||||||
#![warn(clippy::all, rust_2018_idioms)]
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use log::LevelFilter;
|
|
||||||
|
|
||||||
// When compiling natively:
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn main() -> eframe::Result {
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
|
||||||
.filter_level(LevelFilter::Info)
|
|
||||||
.filter_module("las", LevelFilter::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::LasApp::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::LasApp::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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
19
src/main.ts
Normal file
19
src/main.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import * as Sentry from "@sentry/browser";
|
||||||
|
import { BrowserTracing } from "@sentry/tracing";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://8291ffc6948c419591813f3b6ab432d7@o704302.ingest.sentry.io/6603419",
|
||||||
|
integrations: [new BrowserTracing()],
|
||||||
|
|
||||||
|
// Set tracesSampleRate to 1.0 to capture 100%
|
||||||
|
// of transactions for performance monitoring.
|
||||||
|
// We recommend adjusting this value in production
|
||||||
|
tracesSampleRate: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById("app"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
pub(super) fn parse_background_color(style: &str) -> Option<[u8; 3]> {
|
|
||||||
for entry in style.split(';') {
|
|
||||||
let entry = entry.trim();
|
|
||||||
if let Some(value) = entry.strip_prefix("background-color:") {
|
|
||||||
let value = value.trim();
|
|
||||||
return parse_hex_color(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_hex_color(value: &str) -> Option<[u8; 3]> {
|
|
||||||
let hex = value.strip_prefix('#')?.trim();
|
|
||||||
match hex.len() {
|
|
||||||
3 => {
|
|
||||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
|
||||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
|
||||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
|
||||||
Some([r, g, b])
|
|
||||||
}
|
|
||||||
6 => {
|
|
||||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
|
||||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
|
||||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
|
||||||
Some([r, g, b])
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
//! SVG parsing module for extracting special elements from SVG files.
|
|
||||||
|
|
||||||
mod color;
|
|
||||||
mod parser;
|
|
||||||
mod types;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
pub use types::{
|
|
||||||
Anchor, AnchorLink, AudioArea, Renderable, SvgContent, TextElement, TextLine, VideoScroll,
|
|
||||||
};
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
use super::Renderable as _;
|
|
||||||
use super::color::parse_background_color;
|
|
||||||
use super::types::{Anchor, AnchorLink, AudioArea, SvgContent, TextElement, TextLine, VideoScroll};
|
|
||||||
use quick_xml::Reader;
|
|
||||||
use quick_xml::events::Event;
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct LinkAccumulator {
|
|
||||||
target_id: String,
|
|
||||||
min_x: f32,
|
|
||||||
max_x: f32,
|
|
||||||
min_y: f32,
|
|
||||||
max_y: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinkAccumulator {
|
|
||||||
fn new(target_id: String) -> Self {
|
|
||||||
Self {
|
|
||||||
target_id,
|
|
||||||
min_x: f32::INFINITY,
|
|
||||||
max_x: f32::NEG_INFINITY,
|
|
||||||
min_y: f32::INFINITY,
|
|
||||||
max_y: f32::NEG_INFINITY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_bounds(&mut self, x: f32, y: f32, width: f32, height: f32) {
|
|
||||||
if width <= 0.0 || height <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.min_x = self.min_x.min(x);
|
|
||||||
self.min_y = self.min_y.min(y);
|
|
||||||
self.max_x = self.max_x.max(x + width);
|
|
||||||
self.max_y = self.max_y.max(y + height);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finalize(&self) -> Option<(f32, f32, f32, f32)> {
|
|
||||||
if self.min_x.is_finite()
|
|
||||||
&& self.min_y.is_finite()
|
|
||||||
&& self.max_x.is_finite()
|
|
||||||
&& self.max_y.is_finite()
|
|
||||||
&& self.max_x > self.min_x
|
|
||||||
&& self.max_y > self.min_y
|
|
||||||
{
|
|
||||||
Some((
|
|
||||||
self.min_x,
|
|
||||||
self.min_y,
|
|
||||||
self.max_x - self.min_x,
|
|
||||||
self.max_y - self.min_y,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SvgContent {
|
|
||||||
/// Parse an SVG file and extract special elements.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
log::debug!("Reading SVG file: {path}");
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
log::debug!("SVG file size: {} bytes", content.len());
|
|
||||||
Self::parse(&content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse SVG content from a string.
|
|
||||||
#[expect(clippy::too_many_lines)]
|
|
||||||
pub fn parse(content: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
log::debug!("Parsing SVG content ({} bytes)...", content.len());
|
|
||||||
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_font_size = 10.5833f32; // Default from SVG
|
|
||||||
let mut text_lines: Vec<TextLine> = Vec::new();
|
|
||||||
let mut in_tspan = false;
|
|
||||||
let mut tspan_x = 0.0f32;
|
|
||||||
let mut tspan_y = 0.0f32;
|
|
||||||
let mut tspan_content = String::new();
|
|
||||||
|
|
||||||
// Track hyperlink bounds targeting anchors
|
|
||||||
let mut current_link: Option<LinkAccumulator> = None;
|
|
||||||
|
|
||||||
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() {
|
|
||||||
"a" => {
|
|
||||||
for attr in e.attributes().flatten() {
|
|
||||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
|
||||||
let value = String::from_utf8_lossy(&attr.value);
|
|
||||||
if key.as_ref().ends_with("href") {
|
|
||||||
let cleaned = value.trim_start_matches('#');
|
|
||||||
if cleaned.starts_with("anchor") {
|
|
||||||
current_link =
|
|
||||||
Some(LinkAccumulator::new(cleaned.to_owned()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if attr.key.as_ref() == b"style" {
|
|
||||||
let value = String::from_utf8_lossy(&attr.value);
|
|
||||||
if svg_content.background_color.is_none() {
|
|
||||||
svg_content.background_color =
|
|
||||||
parse_background_color(&value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
link.update_bounds(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 });
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
link.update_bounds(cx - r, cy - r, r * 2.0, r * 2.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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 });
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
let width = rx * 2.0;
|
|
||||||
let height = ry * 2.0;
|
|
||||||
link.update_bounds(cx - rx, cy - ry, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
});
|
|
||||||
} else if id == "start" {
|
|
||||||
svg_content.start_rect = Some((x, y, width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
link.update_bounds(x, y, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"text" => {
|
|
||||||
in_text = true;
|
|
||||||
text_lines.clear();
|
|
||||||
text_font_size = 10.5833; // Reset to default
|
|
||||||
|
|
||||||
for attr in e.attributes().flatten() {
|
|
||||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
|
||||||
let value = String::from_utf8_lossy(&attr.value);
|
|
||||||
if key.as_ref() == "style" {
|
|
||||||
// Parse font-size from style attribute
|
|
||||||
if let Some(size_start) = value.find("font-size:") {
|
|
||||||
let size_str = &value[size_start + 10..];
|
|
||||||
if let Some(size_end) =
|
|
||||||
size_str.find(|c: char| !c.is_numeric() && c != '.')
|
|
||||||
{
|
|
||||||
if let Ok(size) = size_str[..size_end].parse::<f32>() {
|
|
||||||
text_font_size = size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tspan" if in_text => {
|
|
||||||
in_tspan = true;
|
|
||||||
tspan_content.clear();
|
|
||||||
tspan_x = 0.0;
|
|
||||||
tspan_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" => tspan_x = value.parse().unwrap_or(0.0),
|
|
||||||
"y" => tspan_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,
|
|
||||||
});
|
|
||||||
} else if id == "start" {
|
|
||||||
svg_content.start_rect = Some((x, y, width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
link.update_bounds(x, y, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Text(ref e)) => {
|
|
||||||
let text = e.unescape().unwrap_or_default();
|
|
||||||
|
|
||||||
if in_tspan {
|
|
||||||
tspan_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;
|
|
||||||
}
|
|
||||||
"a" => {
|
|
||||||
if let Some(link) = current_link.take() {
|
|
||||||
if let Some((x, y, width, height)) = link.finalize() {
|
|
||||||
svg_content.anchor_links.push(AnchorLink {
|
|
||||||
target_id: link.target_id,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tspan" => {
|
|
||||||
if in_tspan {
|
|
||||||
let content = tspan_content.trim().to_owned();
|
|
||||||
if !content.is_empty() && tspan_y != 0.0 {
|
|
||||||
text_lines.push(TextLine {
|
|
||||||
x: tspan_x,
|
|
||||||
y: tspan_y,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
in_tspan = false;
|
|
||||||
tspan_content.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"text" => {
|
|
||||||
if in_text {
|
|
||||||
if !text_lines.is_empty() {
|
|
||||||
svg_content.texts.push(TextElement {
|
|
||||||
lines: text_lines.clone(),
|
|
||||||
font_size: text_font_size,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(ref mut link) = current_link {
|
|
||||||
let text_elem = TextElement {
|
|
||||||
lines: text_lines.clone(),
|
|
||||||
font_size: text_font_size,
|
|
||||||
};
|
|
||||||
let (x, y, w, h) = text_elem.bounds();
|
|
||||||
link.update_bounds(x, y, w, h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
in_text = false;
|
|
||||||
text_lines.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Eof) => break,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"Error parsing SVG at position {}: {:?}",
|
|
||||||
reader.error_position(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
buf.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"SVG parsing complete: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
|
||||||
svg_content.video_scrolls.len(),
|
|
||||||
svg_content.audio_areas.len(),
|
|
||||||
svg_content.anchors.len(),
|
|
||||||
svg_content.texts.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(svg_content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
use super::{Renderable as _, SvgContent};
|
|
||||||
|
|
||||||
#[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" style="font-size:12px">
|
|
||||||
<tspan x="100" y="200">Hello World</tspan>
|
|
||||||
<tspan x="100" y="220">Second Line</tspan>
|
|
||||||
</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.lines.len(), 2);
|
|
||||||
assert_eq!(text.lines[0].x, 100.0);
|
|
||||||
assert_eq!(text.lines[0].y, 200.0);
|
|
||||||
assert_eq!(text.lines[0].content, "Hello World");
|
|
||||||
assert_eq!(text.lines[1].content, "Second Line");
|
|
||||||
assert_eq!(text.font_size, 12.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)));
|
|
||||||
}
|
|
||||||
123
src/svg/types.rs
123
src/svg/types.rs
|
|
@ -1,123 +0,0 @@
|
||||||
/// 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 single line of text (tspan).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TextLine {
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `<text>` element with multiple lines.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TextElement {
|
|
||||||
pub lines: Vec<TextLine>,
|
|
||||||
pub font_size: f32, // Parsed from style attribute
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A hyperlink pointing to an anchor by id.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AnchorLink {
|
|
||||||
pub target_id: String,
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub width: f32,
|
|
||||||
pub height: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderable for TextElement {
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
|
||||||
if self.lines.is_empty() {
|
|
||||||
return (0.0, 0.0, 0.0, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min);
|
|
||||||
let min_y = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.y - self.font_size)
|
|
||||||
.fold(f32::INFINITY, f32::min);
|
|
||||||
|
|
||||||
let max_x = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6)
|
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
|
||||||
let max_y = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.y)
|
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
|
||||||
|
|
||||||
(min_x, min_y, max_x - min_x, max_y - min_y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 anchor_links: Vec<AnchorLink>,
|
|
||||||
pub texts: Vec<TextElement>,
|
|
||||||
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
|
||||||
pub background_color: Option<[u8; 3]>,
|
|
||||||
pub start_rect: Option<(f32, f32, f32, f32)>,
|
|
||||||
}
|
|
||||||
|
|
@ -1,500 +0,0 @@
|
||||||
//! Text rendering cache that pre-renders text to textures for smooth scaling.
|
|
||||||
//!
|
|
||||||
//! Text is rendered at multiple resolutions (mip levels) so zooming in can use
|
|
||||||
//! high-resolution glyphs while zooming out benefits from GPU mipmapping for
|
|
||||||
//! smooth minification.
|
|
||||||
|
|
||||||
use ab_glyph::{Font as _, FontRef, PxScale, ScaleFont as _};
|
|
||||||
use egui::{Color32, ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode};
|
|
||||||
use log::trace;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// Available render scales (mip levels) for pre-rendering text.
|
|
||||||
const RENDER_SCALES: [f32; 6] = [1.0, 4.0, 8.0, 16.0, 32.0, 64.0];
|
|
||||||
|
|
||||||
/// Texture filtering mode for text rendering.
|
|
||||||
/// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases)
|
|
||||||
/// - `NEAREST`: Sharp/pixelated, no interpolation (good for pixel art style)
|
|
||||||
const TEXTURE_OPTIONS: TextureOptions = TextureOptions {
|
|
||||||
magnification: TextureFilter::Linear,
|
|
||||||
minification: TextureFilter::Linear,
|
|
||||||
wrap_mode: TextureWrapMode::ClampToEdge,
|
|
||||||
mipmap_mode: Some(TextureFilter::Linear),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Maximum texture dimension to prevent memory issues.
|
|
||||||
const MAX_TEXTURE_DIM: u32 = 16_384;
|
|
||||||
|
|
||||||
/// Maximum pixel count (256M pixels ≈ 1 GiB for RGBA).
|
|
||||||
const MAX_PIXEL_COUNT: usize = 256 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Default memory cap for the text cache (bytes).
|
|
||||||
const DEFAULT_CACHE_BYTES: usize = 1_024 * 1_024 * 1_024; // 1 GiB
|
|
||||||
|
|
||||||
/// A cached rendered text texture.
|
|
||||||
pub struct CachedText {
|
|
||||||
/// The texture handle for the rendered text.
|
|
||||||
pub texture: TextureHandle,
|
|
||||||
/// Width of the texture in pixels (at render scale).
|
|
||||||
pub width: u32,
|
|
||||||
/// Height of the texture in pixels (at render scale).
|
|
||||||
pub height: u32,
|
|
||||||
/// Render scale that was used to generate the texture.
|
|
||||||
pub render_scale: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cache for rendered text textures.
|
|
||||||
pub struct TextCache {
|
|
||||||
/// The font used for rendering.
|
|
||||||
font: FontRef<'static>,
|
|
||||||
/// Cached text textures, keyed by (content, `size_key`, render scale).
|
|
||||||
cache: HashMap<(String, u32, u32), CachedEntry>,
|
|
||||||
max_bytes: usize,
|
|
||||||
total_bytes: usize,
|
|
||||||
usage_clock: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CachedEntry {
|
|
||||||
text: CachedText,
|
|
||||||
bytes: usize,
|
|
||||||
last_used: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TextCache {
|
|
||||||
/// Create a new text cache with the embedded Noto Sans font.
|
|
||||||
///
|
|
||||||
/// Text is rendered in white - apply color as a tint when drawing.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
log::debug!("Initializing text cache...");
|
|
||||||
let font_data: &'static [u8] = include_bytes!("../assets/NotoSans-Regular.ttf");
|
|
||||||
let font = FontRef::try_from_slice(font_data).expect("embedded font should be valid");
|
|
||||||
log::info!(
|
|
||||||
"Text cache initialized (font: {} bytes, render scales: {:?}, mipmaps: {:?})",
|
|
||||||
font_data.len(),
|
|
||||||
RENDER_SCALES,
|
|
||||||
TEXTURE_OPTIONS.mipmap_mode
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
font,
|
|
||||||
cache: HashMap::new(),
|
|
||||||
max_bytes: DEFAULT_CACHE_BYTES,
|
|
||||||
total_bytes: 0,
|
|
||||||
usage_clock: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of cached text textures.
|
|
||||||
pub fn cache_size(&self) -> usize {
|
|
||||||
self.cache.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns approximate GPU memory used by cached text textures (bytes).
|
|
||||||
pub fn cache_memory_bytes(&self) -> usize {
|
|
||||||
self.total_bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create a cached texture for the given text.
|
|
||||||
///
|
|
||||||
/// The texture is rendered at the smallest available render scale that can
|
|
||||||
/// cover the requested zoom. Higher zoom levels therefore pick a higher
|
|
||||||
/// pre-render scale while lower zooms reuse the mip levels generated by the
|
|
||||||
/// GPU.
|
|
||||||
pub fn get_or_create(
|
|
||||||
&mut self,
|
|
||||||
ctx: &egui::Context,
|
|
||||||
text: &str,
|
|
||||||
nominal_font_size: f32,
|
|
||||||
zoom: f32,
|
|
||||||
) -> &CachedText {
|
|
||||||
// Round font size to reduce cache entries (0.5px granularity)
|
|
||||||
let size_key = (nominal_font_size * 2.0).round() as u32;
|
|
||||||
let render_scale = Self::pick_render_scale(nominal_font_size, zoom, ctx.pixels_per_point());
|
|
||||||
let render_scale_key = Self::render_scale_key(render_scale);
|
|
||||||
let key = (text.to_owned(), size_key, render_scale_key);
|
|
||||||
|
|
||||||
if self.cache.contains_key(&key) {
|
|
||||||
self.usage_clock = self.usage_clock.wrapping_add(1);
|
|
||||||
if let Some(entry) = self.cache.get_mut(&key) {
|
|
||||||
entry.last_used = self.usage_clock;
|
|
||||||
}
|
|
||||||
return self
|
|
||||||
.cache
|
|
||||||
.get(&key)
|
|
||||||
.map(|entry| &entry.text)
|
|
||||||
.expect("entry should exist");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cached = self.render_text(ctx, text, nominal_font_size, size_key, render_scale);
|
|
||||||
let mut bytes = texture_bytes(&cached);
|
|
||||||
|
|
||||||
if bytes > self.max_bytes {
|
|
||||||
log::warn!(
|
|
||||||
"Text texture {} bytes exceeds cache cap {}; using empty texture",
|
|
||||||
bytes,
|
|
||||||
self.max_bytes
|
|
||||||
);
|
|
||||||
cached = Self::create_empty_texture(ctx, size_key, cached.render_scale);
|
|
||||||
bytes = texture_bytes(&cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evict least-recently-used entries until we have room.
|
|
||||||
self.ensure_capacity(bytes);
|
|
||||||
|
|
||||||
self.usage_clock = self.usage_clock.wrapping_add(1);
|
|
||||||
self.cache.insert(
|
|
||||||
key.clone(),
|
|
||||||
CachedEntry {
|
|
||||||
text: cached,
|
|
||||||
bytes,
|
|
||||||
last_used: self.usage_clock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.total_bytes = self.total_bytes.saturating_add(bytes);
|
|
||||||
|
|
||||||
self.cache
|
|
||||||
.get(&key)
|
|
||||||
.map(|entry| &entry.text)
|
|
||||||
.expect("just inserted")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick_render_scale(nominal_font_size: f32, zoom: f32, pixels_per_point: f32) -> f32 {
|
|
||||||
if nominal_font_size <= 0.0 {
|
|
||||||
return RENDER_SCALES[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
let effective_zoom = (zoom * pixels_per_point).max(1.0);
|
|
||||||
let max_scale_allowed = (MAX_TEXTURE_DIM as f32 / nominal_font_size).max(1.0);
|
|
||||||
let max_level = RENDER_SCALES
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|&scale| scale <= max_scale_allowed)
|
|
||||||
.last()
|
|
||||||
.unwrap_or(RENDER_SCALES[0]);
|
|
||||||
|
|
||||||
RENDER_SCALES
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.find(|&scale| scale >= effective_zoom && scale <= max_level)
|
|
||||||
.unwrap_or(max_level)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_scale_key(scale: f32) -> u32 {
|
|
||||||
scale.to_bits()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render text to a texture at high resolution.
|
|
||||||
#[expect(clippy::too_many_lines)]
|
|
||||||
fn render_text(
|
|
||||||
&self,
|
|
||||||
ctx: &egui::Context,
|
|
||||||
text: &str,
|
|
||||||
nominal_font_size: f32,
|
|
||||||
size_key: u32,
|
|
||||||
render_scale: f32,
|
|
||||||
) -> CachedText {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
let start_time = std::time::Instant::now();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
let start_time = wasm_now();
|
|
||||||
trace!(
|
|
||||||
"Rendering text '{}' at size {}px (scale {})...",
|
|
||||||
&text[..text.len().min(20)],
|
|
||||||
nominal_font_size,
|
|
||||||
render_scale
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut warned_fallback = false;
|
|
||||||
let mut first_oversize_dims: Option<(u32, u32)> = None;
|
|
||||||
|
|
||||||
let mut selected = None;
|
|
||||||
for candidate_scale in std::iter::once(render_scale).chain(
|
|
||||||
RENDER_SCALES
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.copied()
|
|
||||||
.filter(|&s| s < render_scale),
|
|
||||||
) {
|
|
||||||
let render_size = nominal_font_size * candidate_scale;
|
|
||||||
let scale = PxScale::from(render_size);
|
|
||||||
let scaled_font = self.font.as_scaled(scale);
|
|
||||||
|
|
||||||
// Calculate text dimensions
|
|
||||||
let height = scaled_font.height();
|
|
||||||
let ascent = scaled_font.ascent();
|
|
||||||
let width = Self::measure_text_width(text, &scaled_font);
|
|
||||||
|
|
||||||
// Early return for empty/invalid text
|
|
||||||
if width <= 0.0 || height <= 0.0 || text.trim().is_empty() {
|
|
||||||
return Self::create_empty_texture(ctx, size_key, candidate_scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
let padding = 2.0;
|
|
||||||
let img_width = (width + padding * 2.0).ceil() as u32;
|
|
||||||
let img_height = (height + padding * 2.0).ceil() as u32;
|
|
||||||
|
|
||||||
if img_width == 0 || img_height == 0 {
|
|
||||||
return Self::create_empty_texture(ctx, size_key, candidate_scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
if img_width > MAX_TEXTURE_DIM || img_height > MAX_TEXTURE_DIM {
|
|
||||||
if first_oversize_dims.is_none() {
|
|
||||||
first_oversize_dims = Some((img_width, img_height));
|
|
||||||
}
|
|
||||||
warned_fallback = warned_fallback || candidate_scale == render_scale;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pixel_count = img_width as usize * img_height as usize;
|
|
||||||
if pixel_count > MAX_PIXEL_COUNT {
|
|
||||||
log::warn!(
|
|
||||||
"Text texture too large: {img_width}x{img_height} for '{}' at scale {} (pixel limit)",
|
|
||||||
&text[..text.len().min(20)],
|
|
||||||
candidate_scale
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
selected = Some((
|
|
||||||
candidate_scale,
|
|
||||||
scaled_font,
|
|
||||||
scale,
|
|
||||||
ascent,
|
|
||||||
padding,
|
|
||||||
img_width,
|
|
||||||
img_height,
|
|
||||||
));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((chosen_scale, scaled_font, scale, ascent, padding, img_width, img_height)) =
|
|
||||||
selected
|
|
||||||
else {
|
|
||||||
if let Some((w, h)) = first_oversize_dims {
|
|
||||||
log::error!(
|
|
||||||
"Text texture exceeds MAX_TEXTURE_DIM ({MAX_TEXTURE_DIM}) at all scales; requested '{}' => {w}x{h}",
|
|
||||||
&text[..text.len().min(20)]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"Unable to render text '{}' within texture limits; no suitable scale found",
|
|
||||||
&text[..text.len().min(20)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Self::create_empty_texture(ctx, size_key, render_scale);
|
|
||||||
};
|
|
||||||
|
|
||||||
if warned_fallback {
|
|
||||||
if let Some((w, h)) = first_oversize_dims {
|
|
||||||
log::warn!(
|
|
||||||
"Requested text scale {} for '{}' would create texture {}x{} (>{MAX_TEXTURE_DIM}); using scale {} instead",
|
|
||||||
render_scale,
|
|
||||||
&text[..text.len().min(20)],
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
chosen_scale
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render glyphs to pixel buffer
|
|
||||||
let pixels = Self::render_glyphs(
|
|
||||||
text,
|
|
||||||
&scaled_font,
|
|
||||||
scale,
|
|
||||||
ascent,
|
|
||||||
padding,
|
|
||||||
img_width,
|
|
||||||
img_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create egui texture
|
|
||||||
let image = ColorImage {
|
|
||||||
size: [img_width as usize, img_height as usize],
|
|
||||||
pixels,
|
|
||||||
source_size: egui::Vec2::new(img_width as f32, img_height as f32),
|
|
||||||
};
|
|
||||||
|
|
||||||
let texture = ctx.load_texture(
|
|
||||||
format!(
|
|
||||||
"text_{size_key}_{}_{}",
|
|
||||||
text.len(),
|
|
||||||
Self::render_scale_key(render_scale)
|
|
||||||
),
|
|
||||||
image,
|
|
||||||
TEXTURE_OPTIONS,
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
let duration = start_time.elapsed();
|
|
||||||
trace!(
|
|
||||||
"Rendered text '{}' ({}x{} @{}) in {:.2?}",
|
|
||||||
&text[..text.len().min(20)],
|
|
||||||
img_width,
|
|
||||||
img_height,
|
|
||||||
chosen_scale,
|
|
||||||
duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
let duration_ms = (wasm_now() - start_time).max(0.0);
|
|
||||||
trace!(
|
|
||||||
"Rendered text '{}' ({}x{} @{}) in {:.2}ms",
|
|
||||||
&text[..text.len().min(20)],
|
|
||||||
img_width,
|
|
||||||
img_height,
|
|
||||||
chosen_scale,
|
|
||||||
duration_ms
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CachedText {
|
|
||||||
texture,
|
|
||||||
width: img_width,
|
|
||||||
height: img_height,
|
|
||||||
render_scale: chosen_scale,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Measure the width of text in pixels.
|
|
||||||
fn measure_text_width(
|
|
||||||
text: &str,
|
|
||||||
scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>,
|
|
||||||
) -> f32 {
|
|
||||||
let mut width = 0.0f32;
|
|
||||||
let mut last_glyph_id = None;
|
|
||||||
|
|
||||||
for c in text.chars() {
|
|
||||||
let glyph_id = scaled_font.glyph_id(c);
|
|
||||||
|
|
||||||
if let Some(last_id) = last_glyph_id {
|
|
||||||
width += scaled_font.kern(last_id, glyph_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
width += scaled_font.h_advance(glyph_id);
|
|
||||||
last_glyph_id = Some(glyph_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
width
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render glyphs to a pixel buffer.
|
|
||||||
fn render_glyphs(
|
|
||||||
text: &str,
|
|
||||||
scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>,
|
|
||||||
scale: PxScale,
|
|
||||||
ascent: f32,
|
|
||||||
padding: f32,
|
|
||||||
img_width: u32,
|
|
||||||
img_height: u32,
|
|
||||||
) -> Vec<Color32> {
|
|
||||||
let mut pixels = vec![Color32::TRANSPARENT; (img_width * img_height) as usize];
|
|
||||||
let mut cursor_x = padding;
|
|
||||||
let mut last_glyph_id = None;
|
|
||||||
|
|
||||||
for c in text.chars() {
|
|
||||||
let glyph_id = scaled_font.glyph_id(c);
|
|
||||||
|
|
||||||
if let Some(last_id) = last_glyph_id {
|
|
||||||
cursor_x += scaled_font.kern(last_id, glyph_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(outlined) = scaled_font.outline_glyph(ab_glyph::Glyph {
|
|
||||||
id: glyph_id,
|
|
||||||
scale,
|
|
||||||
position: ab_glyph::point(cursor_x, ascent + padding),
|
|
||||||
}) {
|
|
||||||
let bounds = outlined.px_bounds();
|
|
||||||
|
|
||||||
outlined.draw(|x, y, coverage| {
|
|
||||||
let px = bounds.min.x as i32 + x as i32;
|
|
||||||
let py = bounds.min.y as i32 + y as i32;
|
|
||||||
|
|
||||||
if px >= 0 && py >= 0 {
|
|
||||||
let px = px as u32;
|
|
||||||
let py = py as u32;
|
|
||||||
|
|
||||||
if px < img_width && py < img_height {
|
|
||||||
let idx = (py * img_width + px) as usize;
|
|
||||||
let alpha = (coverage * 255.0) as u8;
|
|
||||||
let existing = pixels[idx];
|
|
||||||
let new_alpha = alpha.saturating_add(existing.a());
|
|
||||||
|
|
||||||
// Render in white - color is applied as tint when drawing
|
|
||||||
pixels[idx] = Color32::from_rgba_unmultiplied(255, 255, 255, new_alpha);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor_x += scaled_font.h_advance(glyph_id);
|
|
||||||
last_glyph_id = Some(glyph_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a minimal 1x1 transparent texture for empty/invalid text.
|
|
||||||
fn create_empty_texture(ctx: &egui::Context, size_key: u32, render_scale: f32) -> CachedText {
|
|
||||||
let image = ColorImage {
|
|
||||||
size: [1, 1],
|
|
||||||
pixels: vec![Color32::TRANSPARENT],
|
|
||||||
source_size: egui::Vec2::new(1.0, 1.0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let texture = ctx.load_texture(
|
|
||||||
format!(
|
|
||||||
"text_empty_{size_key}_{}",
|
|
||||||
Self::render_scale_key(render_scale)
|
|
||||||
),
|
|
||||||
image,
|
|
||||||
TEXTURE_OPTIONS,
|
|
||||||
);
|
|
||||||
|
|
||||||
CachedText {
|
|
||||||
texture,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
render_scale,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_capacity(&mut self, incoming_bytes: usize) {
|
|
||||||
if incoming_bytes > self.max_bytes {
|
|
||||||
log::warn!(
|
|
||||||
"Incoming text texture ({incoming_bytes} bytes) exceeds cache cap ({}) - returning empty texture",
|
|
||||||
self.max_bytes
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while self.total_bytes.saturating_add(incoming_bytes) > self.max_bytes {
|
|
||||||
if let Some((evict_key, evict_entry)) = self
|
|
||||||
.cache
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|(_, entry)| entry.last_used)
|
|
||||||
.map(|(k, v)| (k.clone(), v.bytes))
|
|
||||||
{
|
|
||||||
self.cache.remove(&evict_key);
|
|
||||||
self.total_bytes = self.total_bytes.saturating_sub(evict_entry);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn wasm_now() -> f64 {
|
|
||||||
web_sys::window()
|
|
||||||
.and_then(|window| window.performance())
|
|
||||||
.map(|performance| performance.now())
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn texture_bytes(cached: &CachedText) -> usize {
|
|
||||||
cached.width as usize * cached.height as usize * 4
|
|
||||||
}
|
|
||||||
8
src/utils.ts
Normal file
8
src/utils.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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];
|
||||||
|
}
|
||||||
3
src/vite-env.d.ts
vendored
Normal file
3
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare const __COMMIT_HASH__: string
|
||||||
7
svelte.config.js
Normal file
7
svelte.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import sveltePreprocess from 'svelte-preprocess'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: sveltePreprocess()
|
||||||
|
}
|
||||||
192
tools/main.ts
Normal file
192
tools/main.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { parse, type RootNode, type ElementNode } from "svg-parser";
|
||||||
|
import fs from "fs";
|
||||||
|
import colors from "colors";
|
||||||
|
import * as path from "path";
|
||||||
|
import { Command } from "commander";
|
||||||
|
const program = new Command();
|
||||||
|
import { formatISO } from "date-fns";
|
||||||
|
import { fileTypeFromFile } from "file-type";
|
||||||
|
import klaw from "klaw";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import cliProgress from "cli-progress";
|
||||||
|
import os, { loadavg } from "os";
|
||||||
|
import async from "async";
|
||||||
|
|
||||||
|
const packageJSON = JSON.parse(
|
||||||
|
fs.readFileSync("package.json", { encoding: "utf-8" })
|
||||||
|
);
|
||||||
|
|
||||||
|
program
|
||||||
|
.name(packageJSON.name)
|
||||||
|
.description(packageJSON.description)
|
||||||
|
.version(packageJSON.version);
|
||||||
|
|
||||||
|
function log(msg: string) {
|
||||||
|
console.log(`[${formatISO(new Date())}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(msg: string) {
|
||||||
|
console.error(`[${formatISO(new Date())}] ${colors.yellow(msg)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(msg: string) {
|
||||||
|
console.error(`[${formatISO(new Date())}] ${colors.red(msg)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("check")
|
||||||
|
.description("Check a SVG file for common errors.")
|
||||||
|
.argument(
|
||||||
|
"[path to SVG file]",
|
||||||
|
"Path to the LaS SVG file.",
|
||||||
|
"../public/content/intro.svg"
|
||||||
|
)
|
||||||
|
.action((fileName: string) => {
|
||||||
|
log(`Loading "${fileName}"`);
|
||||||
|
const fileContents = fs.readFileSync(fileName, { encoding: "utf8" });
|
||||||
|
const root = parse(fileContents);
|
||||||
|
|
||||||
|
const TAGS = ["image", "rect", "circle", "ellipse", "a"];
|
||||||
|
|
||||||
|
const elements: { [key: string]: ElementNode[] } = {};
|
||||||
|
TAGS.forEach((tag) => (elements[tag] = []));
|
||||||
|
function walkAndSort(element: RootNode | ElementNode) {
|
||||||
|
if ("children" in element) {
|
||||||
|
element.children.forEach((child) => {
|
||||||
|
if (typeof child !== "string" && "tagName" in child) {
|
||||||
|
const tagName = child.tagName || "";
|
||||||
|
if (TAGS.includes(tagName)) {
|
||||||
|
elements[tagName].push(child);
|
||||||
|
}
|
||||||
|
walkAndSort(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walkAndSort(root);
|
||||||
|
|
||||||
|
const anchorLinks: ElementNode[] = elements["a"].filter((el) => {
|
||||||
|
const href = String((el.properties || {})["xlink:href"] || "");
|
||||||
|
return !href.startsWith("http") && href.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`Found ${anchorLinks.length} links to anchors.`);
|
||||||
|
|
||||||
|
const validTargets = elements["rect"].map(
|
||||||
|
(el) => el.properties!.id as string
|
||||||
|
);
|
||||||
|
anchorLinks.forEach((el) => {
|
||||||
|
const href = String((el.properties || {})["xlink:href"] || "");
|
||||||
|
if (!validTargets.includes(href)) {
|
||||||
|
const child = el.children.find((el) => typeof el !== "string") as
|
||||||
|
| ElementNode
|
||||||
|
| undefined;
|
||||||
|
const childProps = child?.properties || {};
|
||||||
|
const cx = childProps["x"] || childProps["cx"] || "???";
|
||||||
|
const cy = childProps["y"] || childProps["cy"] || "???";
|
||||||
|
warn(
|
||||||
|
` - Link "${href}" (cx: ${cx}, cy: ${cy}) has no corresponding target object!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// const badAudios = elements["ellipse"].filter((el) => {
|
||||||
|
// if
|
||||||
|
// })
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("process")
|
||||||
|
.description("Process a directory of media content.")
|
||||||
|
.argument("<input>", "Path to the input directory.")
|
||||||
|
.argument("<output>", "Path to the output directory.")
|
||||||
|
.option("-c, --clean", "Clean output directory.")
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
inputDir: string,
|
||||||
|
outputDir: string,
|
||||||
|
options: { clean: boolean }
|
||||||
|
) => {
|
||||||
|
const images: klaw.Item[] = [];
|
||||||
|
const audios: klaw.Item[] = [];
|
||||||
|
|
||||||
|
log(`Processing "${inputDir}"...`);
|
||||||
|
for await (const item of klaw(inputDir)) {
|
||||||
|
if (!item.stats.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileType = await fileTypeFromFile(item.path);
|
||||||
|
if (fileType?.mime?.startsWith("image")) {
|
||||||
|
images.push(item);
|
||||||
|
}
|
||||||
|
if (fileType?.mime?.startsWith("audio")) {
|
||||||
|
audios.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Found ${images.length} images and ${audios.length} audios.`);
|
||||||
|
|
||||||
|
if (options.clean && fs.existsSync(outputDir)) {
|
||||||
|
warn(`Deleting "${outputDir}"!`);
|
||||||
|
fs.rmSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
try {
|
||||||
|
log("Converting all images...");
|
||||||
|
const inputPath = path.resolve(inputDir);
|
||||||
|
const outputPath = fs.mkdtempSync(`${tmpDir}${path.sep}las_`);
|
||||||
|
|
||||||
|
const imagesBar = new cliProgress.SingleBar(
|
||||||
|
{},
|
||||||
|
cliProgress.Presets.shades_classic
|
||||||
|
);
|
||||||
|
|
||||||
|
imagesBar.start(images.length, 0);
|
||||||
|
|
||||||
|
await async.eachLimit(images, os.cpus().length, async (image, cb) => {
|
||||||
|
const fullPath = path.resolve(image.path);
|
||||||
|
const relPath = fullPath.substring(inputPath.length + 1);
|
||||||
|
const destPath = path.join(outputPath, relPath);
|
||||||
|
const destDirPath = path.dirname(destPath);
|
||||||
|
const parsedPath = path.parse(fullPath);
|
||||||
|
|
||||||
|
fs.mkdirSync(destDirPath, { recursive: true });
|
||||||
|
|
||||||
|
// log(`Processing ${relPath}`);
|
||||||
|
|
||||||
|
const lossless = fullPath.endsWith("png");
|
||||||
|
const ext = lossless ? "png" : "jpg";
|
||||||
|
|
||||||
|
let copy = sharp(fullPath);
|
||||||
|
copy = lossless ? copy.png() : copy.jpeg();
|
||||||
|
await copy.toFile(
|
||||||
|
path.join(destDirPath, `${parsedPath.name}.${ext}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// log(`Finished copying ${relPath}`);
|
||||||
|
|
||||||
|
let small = sharp(fullPath).resize(256);
|
||||||
|
small = lossless ? small.png() : small.jpeg();
|
||||||
|
await small.toFile(
|
||||||
|
path.join(destDirPath, `${parsedPath.name}_256.${ext}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// log(`Finished resizing ${relPath}`);
|
||||||
|
|
||||||
|
imagesBar.increment();
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
imagesBar.stop();
|
||||||
|
|
||||||
|
log("Optimizing all images...");
|
||||||
|
// TODO
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
program.parse();
|
||||||
33
tools/package.json
Normal file
33
tools/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "las-tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Tools for LaS authoring.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"tools": "ts-node --esm main.ts"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.4",
|
||||||
|
"cli-progress": "^3.11.2",
|
||||||
|
"colors": "^1.4.0",
|
||||||
|
"commander": "^9.4.0",
|
||||||
|
"date-fns": "^2.29.1",
|
||||||
|
"file-type": "^17.1.4",
|
||||||
|
"imagemin": "^8.0.1",
|
||||||
|
"imagemin-jpegtran": "^7.0.0",
|
||||||
|
"imagemin-pngquant": "^9.0.2",
|
||||||
|
"klaw": "^4.0.1",
|
||||||
|
"sharp": "^0.30.7",
|
||||||
|
"svg-parser": "^2.0.4",
|
||||||
|
"ts-node": "^10.9.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/async": "^3.2.15",
|
||||||
|
"@types/cli-progress": "^3.11.0",
|
||||||
|
"@types/klaw": "^3.0.3",
|
||||||
|
"@types/node": "^18.6.3",
|
||||||
|
"@types/sharp": "^0.30.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tools/tsconfig.json
Normal file
8
tools/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
2341
tools/yarn.lock
Normal file
2341
tools/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
8
tsconfig.node.json
Normal file
8
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import cp from "child_process";
|
||||||
|
|
||||||
|
const commitHash = cp.execSync("git rev-parse --short HEAD").toString();
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
base: "",
|
||||||
|
define: {
|
||||||
|
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||||
|
},
|
||||||
|
});
|
||||||
835
yarn.lock
Normal file
835
yarn.lock
Normal file
|
|
@ -0,0 +1,835 @@
|
||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri@^3.0.3":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
|
||||||
|
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec@^1.4.10":
|
||||||
|
version "1.4.14"
|
||||||
|
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
||||||
|
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping@^0.3.9":
|
||||||
|
version "0.3.14"
|
||||||
|
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
|
||||||
|
integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==
|
||||||
|
dependencies:
|
||||||
|
"@jridgewell/resolve-uri" "^3.0.3"
|
||||||
|
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||||
|
|
||||||
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
|
version "2.1.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
|
||||||
|
integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
|
||||||
|
dependencies:
|
||||||
|
"@nodelib/fs.stat" "2.0.5"
|
||||||
|
run-parallel "^1.1.9"
|
||||||
|
|
||||||
|
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
|
||||||
|
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||||
|
|
||||||
|
"@nodelib/fs.walk@^1.2.3":
|
||||||
|
version "1.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
|
||||||
|
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
|
||||||
|
dependencies:
|
||||||
|
"@nodelib/fs.scandir" "2.1.5"
|
||||||
|
fastq "^1.6.0"
|
||||||
|
|
||||||
|
"@rollup/pluginutils@^4.2.1":
|
||||||
|
version "4.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
|
||||||
|
integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==
|
||||||
|
dependencies:
|
||||||
|
estree-walker "^2.0.1"
|
||||||
|
picomatch "^2.2.2"
|
||||||
|
|
||||||
|
"@sentry/browser@^7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.7.0.tgz#7810ee98d4969bd0367e29ac0af6c5779db7e6c4"
|
||||||
|
integrity sha512-oyzpWcsjVZTaf14zAL89Ng1DUHlbjN+V8pl8dR9Y9anphbzL5BK9p0fSK4kPIrO4GukK+XrKnLJDPuE/o7WR3g==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/core" "7.7.0"
|
||||||
|
"@sentry/types" "7.7.0"
|
||||||
|
"@sentry/utils" "7.7.0"
|
||||||
|
tslib "^1.9.3"
|
||||||
|
|
||||||
|
"@sentry/core@7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.7.0.tgz#1a2d477897552d179380f7c54c7d81a4e98ea29a"
|
||||||
|
integrity sha512-Z15ACiuiFINFcK4gbMrnejLn4AVjKBPJOWKrrmpIe8mh+Y9diOuswt5mMUABs+jhpZvqht3PBLLGBL0WMsYMYA==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/hub" "7.7.0"
|
||||||
|
"@sentry/types" "7.7.0"
|
||||||
|
"@sentry/utils" "7.7.0"
|
||||||
|
tslib "^1.9.3"
|
||||||
|
|
||||||
|
"@sentry/hub@7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.7.0.tgz#9ad3471cf5ecaf1a9d3a3a04ca2515ffec9e2c09"
|
||||||
|
integrity sha512-6gydK234+a0nKhBRDdIJ7Dp42CaiW2juTiHegUVDq+482balVzbZyEAmESCmuzKJhx5BhlCElVxs/cci1NjMpg==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/types" "7.7.0"
|
||||||
|
"@sentry/utils" "7.7.0"
|
||||||
|
tslib "^1.9.3"
|
||||||
|
|
||||||
|
"@sentry/tracing@^7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.7.0.tgz#67324b755a28e115289755e44a0b8b467a63d0b2"
|
||||||
|
integrity sha512-HNmvTwemuc21q/K6HXsSp9njkne6N1JQ71TB+QGqYU5VtxsVgYSUhhYqV6WcHz7LK4Hj6TvNFoeu69/rO0ysgw==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/hub" "7.7.0"
|
||||||
|
"@sentry/types" "7.7.0"
|
||||||
|
"@sentry/utils" "7.7.0"
|
||||||
|
tslib "^1.9.3"
|
||||||
|
|
||||||
|
"@sentry/types@7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.7.0.tgz#dd6bd3d119d7efea0e85dbaa4b17de1c22b63c7a"
|
||||||
|
integrity sha512-4x8O7uerSGLnYC10krHl9t8h7xXHn5FextqKYbTCXCnx2hC8D+9lz8wcbQAFo0d97wiUYqI8opmEgFVGx7c5hQ==
|
||||||
|
|
||||||
|
"@sentry/utils@7.7.0":
|
||||||
|
version "7.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.7.0.tgz#013e3097c4268a76de578494c7df999635fb0ad4"
|
||||||
|
integrity sha512-fD+ROSFpeJlK7bEvUT2LOW7QqgjBpXJwVISKZ0P2fuzclRC3KoB2pbZgBM4PXMMTiSzRGWhvfRRjBiBvQJBBJQ==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/types" "7.7.0"
|
||||||
|
tslib "^1.9.3"
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte@^1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-1.0.1.tgz#7f468f03c933fcdfc60d4773671c73f33b9ef4d6"
|
||||||
|
integrity sha512-PorCgUounn0VXcpeJu+hOweZODKmGuLHsLomwqSj+p26IwjjGffmYQfVHtiTWq+NqaUuuHWWG7vPge6UFw4Aeg==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^4.2.1"
|
||||||
|
debug "^4.3.4"
|
||||||
|
deepmerge "^4.2.2"
|
||||||
|
kleur "^4.1.5"
|
||||||
|
magic-string "^0.26.2"
|
||||||
|
svelte-hmr "^0.14.12"
|
||||||
|
|
||||||
|
"@tsconfig/svelte@^3.0.0":
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tsconfig/svelte/-/svelte-3.0.0.tgz#b06e059209f04c414de0069f2f0e2796d979fc6f"
|
||||||
|
integrity sha512-pYrtLtOwku/7r1i9AMONsJMVYAtk3hzOfiGNekhtq5tYBGA7unMve8RvUclKLMT3PrihvJqUmzsRGh0RP84hKg==
|
||||||
|
|
||||||
|
"@types/node@*":
|
||||||
|
version "18.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5"
|
||||||
|
integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==
|
||||||
|
|
||||||
|
"@types/pug@^2.0.4":
|
||||||
|
version "2.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.6.tgz#f830323c88172e66826d0bde413498b61054b5a6"
|
||||||
|
integrity sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==
|
||||||
|
|
||||||
|
"@types/sass@^1.16.0":
|
||||||
|
version "1.43.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/sass/-/sass-1.43.1.tgz#86bb0168e9e881d7dade6eba16c9ed6d25dc2f68"
|
||||||
|
integrity sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/stats.js@^0.17.0":
|
||||||
|
version "0.17.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.0.tgz#0ed81d48e03b590c24da85540c1d952077a9fe20"
|
||||||
|
integrity sha512-9w+a7bR8PeB0dCT/HBULU2fMqf6BAzvKbxFboYhmDtDkKPiyXYbjoe2auwsXlEFI7CFNMF1dCv3dFH5Poy9R1w==
|
||||||
|
|
||||||
|
amator@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/amator/-/amator-1.1.0.tgz#08c6b60bc93aec2b61bbfc0c4d677d30323cc0f1"
|
||||||
|
integrity sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==
|
||||||
|
dependencies:
|
||||||
|
bezier-easing "^2.0.3"
|
||||||
|
|
||||||
|
anymatch@~3.1.2:
|
||||||
|
version "3.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
|
||||||
|
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
|
||||||
|
dependencies:
|
||||||
|
normalize-path "^3.0.0"
|
||||||
|
picomatch "^2.0.4"
|
||||||
|
|
||||||
|
balanced-match@^1.0.0:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
|
bezier-easing@^2.0.3:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/bezier-easing/-/bezier-easing-2.1.0.tgz#c04dfe8b926d6ecaca1813d69ff179b7c2025d86"
|
||||||
|
integrity sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==
|
||||||
|
|
||||||
|
binary-extensions@^2.0.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
|
||||||
|
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
|
||||||
|
|
||||||
|
brace-expansion@^1.1.7:
|
||||||
|
version "1.1.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||||
|
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^1.0.0"
|
||||||
|
concat-map "0.0.1"
|
||||||
|
|
||||||
|
braces@^3.0.2, braces@~3.0.2:
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||||
|
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||||
|
dependencies:
|
||||||
|
fill-range "^7.0.1"
|
||||||
|
|
||||||
|
buffer-crc32@^0.2.5:
|
||||||
|
version "0.2.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
|
||||||
|
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
|
||||||
|
|
||||||
|
callsites@^3.0.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
|
||||||
|
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
|
||||||
|
|
||||||
|
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1:
|
||||||
|
version "3.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||||
|
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||||
|
dependencies:
|
||||||
|
anymatch "~3.1.2"
|
||||||
|
braces "~3.0.2"
|
||||||
|
glob-parent "~5.1.2"
|
||||||
|
is-binary-path "~2.1.0"
|
||||||
|
is-glob "~4.0.1"
|
||||||
|
normalize-path "~3.0.0"
|
||||||
|
readdirp "~3.6.0"
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
concat-map@0.0.1:
|
||||||
|
version "0.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||||
|
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||||
|
|
||||||
|
debug@^4.3.4:
|
||||||
|
version "4.3.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||||
|
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||||
|
dependencies:
|
||||||
|
ms "2.1.2"
|
||||||
|
|
||||||
|
deepmerge@^4.2.2:
|
||||||
|
version "4.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
|
||||||
|
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
|
||||||
|
|
||||||
|
detect-indent@^6.0.0:
|
||||||
|
version "6.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
|
||||||
|
integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
|
||||||
|
|
||||||
|
es6-promise@^3.1.2:
|
||||||
|
version "3.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
|
||||||
|
integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==
|
||||||
|
|
||||||
|
esbuild-android-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.50.tgz#a46fc80fa2007690e647680d837483a750a3097f"
|
||||||
|
integrity sha512-H7iUEm7gUJHzidsBlFPGF6FTExazcgXL/46xxLo6i6bMtPim6ZmXyTccS8yOMpy6HAC6dPZ/JCQqrkkin69n6Q==
|
||||||
|
|
||||||
|
esbuild-android-arm64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.50.tgz#bdda7851fa7f5f770d6ff0ad593a8945d3a0fcdd"
|
||||||
|
integrity sha512-NFaoqEwa+OYfoYVpQWDMdKII7wZZkAjtJFo1WdnBeCYlYikvUhTnf2aPwPu5qEAw/ie1NYK0yn3cafwP+kP+OQ==
|
||||||
|
|
||||||
|
esbuild-darwin-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.50.tgz#f0535435f9760766f30db14a991ee5ca94c022a4"
|
||||||
|
integrity sha512-gDQsCvGnZiJv9cfdO48QqxkRV8oKAXgR2CGp7TdIpccwFdJMHf8hyIJhMW/05b/HJjET/26Us27Jx91BFfEVSA==
|
||||||
|
|
||||||
|
esbuild-darwin-arm64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.50.tgz#76a41a40e8947a15ae62970e9ed2853883c4b16c"
|
||||||
|
integrity sha512-36nNs5OjKIb/Q50Sgp8+rYW/PqirRiFN0NFc9hEvgPzNJxeJedktXwzfJSln4EcRFRh5Vz4IlqFRScp+aiBBzA==
|
||||||
|
|
||||||
|
esbuild-freebsd-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.50.tgz#2ed6633c17ed42c20a1bd68e82c4bbc75ea4fb57"
|
||||||
|
integrity sha512-/1pHHCUem8e/R86/uR+4v5diI2CtBdiWKiqGuPa9b/0x3Nwdh5AOH7lj+8823C6uX1e0ufwkSLkS+aFZiBCWxA==
|
||||||
|
|
||||||
|
esbuild-freebsd-arm64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.50.tgz#cb115f4cdafe9cdbe58875ba482fccc54d32aa43"
|
||||||
|
integrity sha512-iKwUVMQztnPZe5pUYHdMkRc9aSpvoV1mkuHlCoPtxZA3V+Kg/ptpzkcSY+fKd0kuom+l6Rc93k0UPVkP7xoqrw==
|
||||||
|
|
||||||
|
esbuild-linux-32@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.50.tgz#fe2b724994dcf1d4e48dc4832ff008ad7d00bcfd"
|
||||||
|
integrity sha512-sWUwvf3uz7dFOpLzYuih+WQ7dRycrBWHCdoXJ4I4XdMxEHCECd8b7a9N9u7FzT6XR2gHPk9EzvchQUtiEMRwqw==
|
||||||
|
|
||||||
|
esbuild-linux-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.50.tgz#7851ab5151df9501a2187bd4909c594ad232b623"
|
||||||
|
integrity sha512-u0PQxPhaeI629t4Y3EEcQ0wmWG+tC/LpP2K7yDFvwuPq0jSQ8SIN+ARNYfRjGW15O2we3XJvklbGV0wRuUCPig==
|
||||||
|
|
||||||
|
esbuild-linux-arm64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.50.tgz#76a76afef484a0512f1fbbcc762edd705dee8892"
|
||||||
|
integrity sha512-ZyfoNgsTftD7Rp5S7La5auomKdNeB3Ck+kSKXC4pp96VnHyYGjHHXWIlcbH8i+efRn9brszo1/Thl1qn8RqmhQ==
|
||||||
|
|
||||||
|
esbuild-linux-arm@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.50.tgz#6d7a8c0712091b0c3a668dd5d8b5c924adbaeb12"
|
||||||
|
integrity sha512-VALZq13bhmFJYFE/mLEb+9A0w5vo8z+YDVOWeaf9vOTrSC31RohRIwtxXBnVJ7YKLYfEMzcgFYf+OFln3Y0cWg==
|
||||||
|
|
||||||
|
esbuild-linux-mips64le@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.50.tgz#43426909c1884c5dc6b40765673a08a7ec1d2064"
|
||||||
|
integrity sha512-ygo31Vxn/WrmjKCHkBoutOlFG5yM9J2UhzHb0oWD9O61dGg+Hzjz9hjf5cmM7FBhAzdpOdEWHIrVOg2YAi6rTw==
|
||||||
|
|
||||||
|
esbuild-linux-ppc64le@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.50.tgz#c754ea3da1dd180c6e9b6b508dc18ce983d92b11"
|
||||||
|
integrity sha512-xWCKU5UaiTUT6Wz/O7GKP9KWdfbsb7vhfgQzRfX4ahh5NZV4ozZ4+SdzYG8WxetsLy84UzLX3Pi++xpVn1OkFQ==
|
||||||
|
|
||||||
|
esbuild-linux-riscv64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.50.tgz#f3b2dd3c4c2b91bf191d3b98a9819c8aa6f5ad7f"
|
||||||
|
integrity sha512-0+dsneSEihZTopoO9B6Z6K4j3uI7EdxBP7YSF5rTwUgCID+wHD3vM1gGT0m+pjCW+NOacU9kH/WE9N686FHAJg==
|
||||||
|
|
||||||
|
esbuild-linux-s390x@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.50.tgz#3dfbc4578b2a81995caabb79df2b628ea86a5390"
|
||||||
|
integrity sha512-tVjqcu8o0P9H4StwbIhL1sQYm5mWATlodKB6dpEZFkcyTI8kfIGWiWcrGmkNGH2i1kBUOsdlBafPxR3nzp3TDA==
|
||||||
|
|
||||||
|
esbuild-netbsd-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.50.tgz#17dbf51eaa48d983e794b588d195415410ef8c85"
|
||||||
|
integrity sha512-0R/glfqAQ2q6MHDf7YJw/TulibugjizBxyPvZIcorH0Mb7vSimdHy0XF5uCba5CKt+r4wjax1mvO9lZ4jiAhEg==
|
||||||
|
|
||||||
|
esbuild-openbsd-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.50.tgz#cf6b1a50c8cf67b0725aaa4bce9773976168c50e"
|
||||||
|
integrity sha512-7PAtmrR5mDOFubXIkuxYQ4bdNS6XCK8AIIHUiZxq1kL8cFIH5731jPcXQ4JNy/wbj1C9sZ8rzD8BIM80Tqk29w==
|
||||||
|
|
||||||
|
esbuild-sunos-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.50.tgz#f705ae0dd914c3b45dc43319c4f532216c3d841f"
|
||||||
|
integrity sha512-gBxNY/wyptvD7PkHIYcq7se6SQEXcSC8Y7mE0FJB+CGgssEWf6vBPfTTZ2b6BWKnmaP6P6qb7s/KRIV5T2PxsQ==
|
||||||
|
|
||||||
|
esbuild-windows-32@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.50.tgz#6364905a99c1e6c1e2fe7bfccebd958131b1cd6c"
|
||||||
|
integrity sha512-MOOe6J9cqe/iW1qbIVYSAqzJFh0p2LBLhVUIWdMVnNUNjvg2/4QNX4oT4IzgDeldU+Bym9/Tn6+DxvUHJXL5Zw==
|
||||||
|
|
||||||
|
esbuild-windows-64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.50.tgz#56603cb6367e30d14098deb77de6aa18d76dd89b"
|
||||||
|
integrity sha512-r/qE5Ex3w1jjGv/JlpPoWB365ldkppUlnizhMxJgojp907ZF1PgLTuW207kgzZcSCXyquL9qJkMsY+MRtaZ5yQ==
|
||||||
|
|
||||||
|
esbuild-windows-arm64@0.14.50:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.50.tgz#e7ddde6a97194051a5a4ac05f4f5900e922a7ea5"
|
||||||
|
integrity sha512-EMS4lQnsIe12ZyAinOINx7eq2mjpDdhGZZWDwPZE/yUTN9cnc2Ze/xUTYIAyaJqrqQda3LnDpADKpvLvol6ENQ==
|
||||||
|
|
||||||
|
esbuild@^0.14.47:
|
||||||
|
version "0.14.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.50.tgz#7a665392c8df94bf6e1ae1e999966a5ee62c6cbc"
|
||||||
|
integrity sha512-SbC3k35Ih2IC6trhbMYW7hYeGdjPKf9atTKwBUHqMCYFZZ9z8zhuvfnZihsnJypl74FjiAKjBRqFkBkAd0rS/w==
|
||||||
|
optionalDependencies:
|
||||||
|
esbuild-android-64 "0.14.50"
|
||||||
|
esbuild-android-arm64 "0.14.50"
|
||||||
|
esbuild-darwin-64 "0.14.50"
|
||||||
|
esbuild-darwin-arm64 "0.14.50"
|
||||||
|
esbuild-freebsd-64 "0.14.50"
|
||||||
|
esbuild-freebsd-arm64 "0.14.50"
|
||||||
|
esbuild-linux-32 "0.14.50"
|
||||||
|
esbuild-linux-64 "0.14.50"
|
||||||
|
esbuild-linux-arm "0.14.50"
|
||||||
|
esbuild-linux-arm64 "0.14.50"
|
||||||
|
esbuild-linux-mips64le "0.14.50"
|
||||||
|
esbuild-linux-ppc64le "0.14.50"
|
||||||
|
esbuild-linux-riscv64 "0.14.50"
|
||||||
|
esbuild-linux-s390x "0.14.50"
|
||||||
|
esbuild-netbsd-64 "0.14.50"
|
||||||
|
esbuild-openbsd-64 "0.14.50"
|
||||||
|
esbuild-sunos-64 "0.14.50"
|
||||||
|
esbuild-windows-32 "0.14.50"
|
||||||
|
esbuild-windows-64 "0.14.50"
|
||||||
|
esbuild-windows-arm64 "0.14.50"
|
||||||
|
|
||||||
|
estree-walker@^2.0.1:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
||||||
|
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||||
|
|
||||||
|
fast-glob@^3.2.7:
|
||||||
|
version "3.2.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
|
||||||
|
integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
|
||||||
|
dependencies:
|
||||||
|
"@nodelib/fs.stat" "^2.0.2"
|
||||||
|
"@nodelib/fs.walk" "^1.2.3"
|
||||||
|
glob-parent "^5.1.2"
|
||||||
|
merge2 "^1.3.0"
|
||||||
|
micromatch "^4.0.4"
|
||||||
|
|
||||||
|
fastq@^1.6.0:
|
||||||
|
version "1.13.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
|
||||||
|
integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
|
||||||
|
dependencies:
|
||||||
|
reusify "^1.0.4"
|
||||||
|
|
||||||
|
fetch-progress@^1.3.0:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fetch-progress/-/fetch-progress-1.3.0.tgz#5a9992743fc08b0480d4088edc155c85eb44f6eb"
|
||||||
|
integrity sha512-BCeKkVRx0x4mk/ykGGJ9FA2oJgrSp/lQgMiy2Ub+S2SMipt+po2uULUBM3OMOM/5XiwPpM4QyYmbYv/e98NWng==
|
||||||
|
|
||||||
|
fill-range@^7.0.1:
|
||||||
|
version "7.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
|
||||||
|
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
|
||||||
|
dependencies:
|
||||||
|
to-regex-range "^5.0.1"
|
||||||
|
|
||||||
|
fs.realpath@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||||
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
|
fsevents@~2.3.2:
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||||
|
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||||
|
|
||||||
|
function-bind@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||||
|
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||||
|
|
||||||
|
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
|
||||||
|
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
||||||
|
dependencies:
|
||||||
|
is-glob "^4.0.1"
|
||||||
|
|
||||||
|
glob@^7.1.3:
|
||||||
|
version "7.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||||
|
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
|
||||||
|
dependencies:
|
||||||
|
fs.realpath "^1.0.0"
|
||||||
|
inflight "^1.0.4"
|
||||||
|
inherits "2"
|
||||||
|
minimatch "^3.1.1"
|
||||||
|
once "^1.3.0"
|
||||||
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
graceful-fs@^4.1.3:
|
||||||
|
version "4.2.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
|
||||||
|
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
|
||||||
|
|
||||||
|
has@^1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
|
||||||
|
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||||
|
dependencies:
|
||||||
|
function-bind "^1.1.1"
|
||||||
|
|
||||||
|
immutable@^4.0.0:
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
|
||||||
|
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
|
||||||
|
|
||||||
|
import-fresh@^3.2.1:
|
||||||
|
version "3.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
||||||
|
integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
|
||||||
|
dependencies:
|
||||||
|
parent-module "^1.0.0"
|
||||||
|
resolve-from "^4.0.0"
|
||||||
|
|
||||||
|
inflight@^1.0.4:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||||
|
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
|
||||||
|
dependencies:
|
||||||
|
once "^1.3.0"
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
inherits@2:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
is-binary-path@~2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
||||||
|
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
|
||||||
|
dependencies:
|
||||||
|
binary-extensions "^2.0.0"
|
||||||
|
|
||||||
|
is-core-module@^2.9.0:
|
||||||
|
version "2.9.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
|
||||||
|
integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
|
||||||
|
dependencies:
|
||||||
|
has "^1.0.3"
|
||||||
|
|
||||||
|
is-extglob@^2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||||
|
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||||
|
|
||||||
|
is-glob@^4.0.1, is-glob@~4.0.1:
|
||||||
|
version "4.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||||
|
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||||
|
dependencies:
|
||||||
|
is-extglob "^2.1.1"
|
||||||
|
|
||||||
|
is-number@^7.0.0:
|
||||||
|
version "7.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||||
|
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||||
|
|
||||||
|
kleur@^4.1.5:
|
||||||
|
version "4.1.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
|
||||||
|
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
|
||||||
|
|
||||||
|
magic-string@^0.25.7:
|
||||||
|
version "0.25.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
|
||||||
|
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
|
||||||
|
dependencies:
|
||||||
|
sourcemap-codec "^1.4.8"
|
||||||
|
|
||||||
|
magic-string@^0.26.2:
|
||||||
|
version "0.26.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.26.2.tgz#5331700e4158cd6befda738bb6b0c7b93c0d4432"
|
||||||
|
integrity sha512-NzzlXpclt5zAbmo6h6jNc8zl2gNRGHvmsZW4IvZhTC4W7k4OlLP+S5YLussa/r3ixNT66KOQfNORlXHSOy/X4A==
|
||||||
|
dependencies:
|
||||||
|
sourcemap-codec "^1.4.8"
|
||||||
|
|
||||||
|
merge2@^1.3.0:
|
||||||
|
version "1.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||||
|
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||||
|
|
||||||
|
micromatch@^4.0.4:
|
||||||
|
version "4.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||||
|
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||||
|
dependencies:
|
||||||
|
braces "^3.0.2"
|
||||||
|
picomatch "^2.3.1"
|
||||||
|
|
||||||
|
min-indent@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
|
||||||
|
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
|
||||||
|
|
||||||
|
minimatch@^3.1.1:
|
||||||
|
version "3.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||||
|
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
|
minimist@^1.2.0, minimist@^1.2.6:
|
||||||
|
version "1.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
|
||||||
|
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
|
||||||
|
|
||||||
|
mkdirp@^0.5.1:
|
||||||
|
version "0.5.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
|
||||||
|
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
|
||||||
|
dependencies:
|
||||||
|
minimist "^1.2.6"
|
||||||
|
|
||||||
|
mri@^1.1.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
||||||
|
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
|
||||||
|
|
||||||
|
ms@2.1.2:
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||||
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
|
nanoid@^3.3.4:
|
||||||
|
version "3.3.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||||
|
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||||
|
|
||||||
|
ngraph.events@^1.2.2:
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ngraph.events/-/ngraph.events-1.2.2.tgz#3ceb92d676a04a4e7ce60a09fa8e17a4f0346d7f"
|
||||||
|
integrity sha512-JsUbEOzANskax+WSYiAPETemLWYXmixuPAlmZmhIbIj6FH/WDgEGCGnRwUQBK0GjOnVm8Ui+e5IJ+5VZ4e32eQ==
|
||||||
|
|
||||||
|
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||||
|
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||||
|
|
||||||
|
normalize.css@^8.0.1:
|
||||||
|
version "8.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
|
||||||
|
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==
|
||||||
|
|
||||||
|
once@^1.3.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||||
|
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
|
||||||
|
dependencies:
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
panzoom@^9.4.3:
|
||||||
|
version "9.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/panzoom/-/panzoom-9.4.3.tgz#195c4031bb643f2e6c42f1de0ca87cc10e224042"
|
||||||
|
integrity sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==
|
||||||
|
dependencies:
|
||||||
|
amator "^1.1.0"
|
||||||
|
ngraph.events "^1.2.2"
|
||||||
|
wheel "^1.0.0"
|
||||||
|
|
||||||
|
parent-module@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||||
|
integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
|
||||||
|
dependencies:
|
||||||
|
callsites "^3.0.0"
|
||||||
|
|
||||||
|
path-is-absolute@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||||
|
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
||||||
|
|
||||||
|
path-parse@^1.0.7:
|
||||||
|
version "1.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||||
|
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||||
|
|
||||||
|
picocolors@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||||
|
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||||
|
|
||||||
|
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
|
||||||
|
version "2.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||||
|
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||||
|
|
||||||
|
postcss@^8.4.14:
|
||||||
|
version "8.4.14"
|
||||||
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
|
||||||
|
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
|
||||||
|
dependencies:
|
||||||
|
nanoid "^3.3.4"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
|
queue-microtask@^1.2.2:
|
||||||
|
version "1.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||||
|
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||||
|
|
||||||
|
readdirp@~3.6.0:
|
||||||
|
version "3.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
|
||||||
|
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
|
||||||
|
dependencies:
|
||||||
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
resolve-from@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||||
|
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
||||||
|
|
||||||
|
resolve@^1.22.1:
|
||||||
|
version "1.22.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
|
||||||
|
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
|
||||||
|
dependencies:
|
||||||
|
is-core-module "^2.9.0"
|
||||||
|
path-parse "^1.0.7"
|
||||||
|
supports-preserve-symlinks-flag "^1.0.0"
|
||||||
|
|
||||||
|
reusify@^1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
|
||||||
|
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||||
|
|
||||||
|
rimraf@^2.5.2:
|
||||||
|
version "2.7.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
|
||||||
|
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
|
||||||
|
dependencies:
|
||||||
|
glob "^7.1.3"
|
||||||
|
|
||||||
|
rollup@^2.75.6:
|
||||||
|
version "2.77.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.1.tgz#63463ebdbc04232fc42630ec72d137cd4400975d"
|
||||||
|
integrity sha512-GhutNJrvTYD6s1moo+kyq7lD9DeR5HDyXo4bDFlDSkepC9kVKY+KK/NSZFzCmeXeia3kEzVuToQmHRdugyZHxw==
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
run-parallel@^1.1.9:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
|
||||||
|
integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
|
||||||
|
dependencies:
|
||||||
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
|
sade@^1.7.4:
|
||||||
|
version "1.8.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
|
||||||
|
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
|
||||||
|
dependencies:
|
||||||
|
mri "^1.1.0"
|
||||||
|
|
||||||
|
sander@^0.5.0:
|
||||||
|
version "0.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/sander/-/sander-0.5.1.tgz#741e245e231f07cafb6fdf0f133adfa216a502ad"
|
||||||
|
integrity sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==
|
||||||
|
dependencies:
|
||||||
|
es6-promise "^3.1.2"
|
||||||
|
graceful-fs "^4.1.3"
|
||||||
|
mkdirp "^0.5.1"
|
||||||
|
rimraf "^2.5.2"
|
||||||
|
|
||||||
|
sass@^1.54.0:
|
||||||
|
version "1.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.0.tgz#24873673265e2a4fe3d3a997f714971db2fba1f4"
|
||||||
|
integrity sha512-C4zp79GCXZfK0yoHZg+GxF818/aclhp9F48XBu/+bm9vXEVAYov9iU3FBVRMq3Hx3OA4jfKL+p2K9180mEh0xQ==
|
||||||
|
dependencies:
|
||||||
|
chokidar ">=3.0.0 <4.0.0"
|
||||||
|
immutable "^4.0.0"
|
||||||
|
source-map-js ">=0.6.2 <2.0.0"
|
||||||
|
|
||||||
|
sorcery@^0.10.0:
|
||||||
|
version "0.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/sorcery/-/sorcery-0.10.0.tgz#8ae90ad7d7cb05fc59f1ab0c637845d5c15a52b7"
|
||||||
|
integrity sha512-R5ocFmKZQFfSTstfOtHjJuAwbpGyf9qjQa1egyhvXSbM7emjrtLXtGdZsDJDABC85YBfVvrOiGWKSYXPKdvP1g==
|
||||||
|
dependencies:
|
||||||
|
buffer-crc32 "^0.2.5"
|
||||||
|
minimist "^1.2.0"
|
||||||
|
sander "^0.5.0"
|
||||||
|
sourcemap-codec "^1.3.0"
|
||||||
|
|
||||||
|
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
|
sourcemap-codec@^1.3.0, sourcemap-codec@^1.4.8:
|
||||||
|
version "1.4.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||||
|
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||||
|
|
||||||
|
stats.js@^0.17.0:
|
||||||
|
version "0.17.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/stats.js/-/stats.js-0.17.0.tgz#b1c3dc46d94498b578b7fd3985b81ace7131cc7d"
|
||||||
|
integrity sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==
|
||||||
|
|
||||||
|
strip-indent@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
|
||||||
|
integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
|
||||||
|
dependencies:
|
||||||
|
min-indent "^1.0.0"
|
||||||
|
|
||||||
|
supports-preserve-symlinks-flag@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||||
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
|
svelte-check@^2.8.0:
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-2.8.0.tgz#cfe1354e72545839c47f0f022c2c007454cd4095"
|
||||||
|
integrity sha512-HRL66BxffMAZusqe5I5k26mRWQ+BobGd9Rxm3onh7ZVu0nTk8YTKJ9vu3LVPjUGLU9IX7zS+jmwPVhJYdXJ8vg==
|
||||||
|
dependencies:
|
||||||
|
"@jridgewell/trace-mapping" "^0.3.9"
|
||||||
|
chokidar "^3.4.1"
|
||||||
|
fast-glob "^3.2.7"
|
||||||
|
import-fresh "^3.2.1"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
sade "^1.7.4"
|
||||||
|
svelte-preprocess "^4.0.0"
|
||||||
|
typescript "*"
|
||||||
|
|
||||||
|
svelte-hmr@^0.14.12:
|
||||||
|
version "0.14.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/svelte-hmr/-/svelte-hmr-0.14.12.tgz#a127aec02f1896500b10148b2d4d21ddde39973f"
|
||||||
|
integrity sha512-4QSW/VvXuqVcFZ+RhxiR8/newmwOCTlbYIezvkeN6302YFRE8cXy0naamHcjz8Y9Ce3ITTZtrHrIL0AGfyo61w==
|
||||||
|
|
||||||
|
svelte-preprocess@^4.0.0, svelte-preprocess@^4.10.7:
|
||||||
|
version "4.10.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-4.10.7.tgz#3626de472f51ffe20c9bc71eff5a3da66797c362"
|
||||||
|
integrity sha512-sNPBnqYD6FnmdBrUmBCaqS00RyCsCpj2BG58A1JBswNF7b0OKviwxqVrOL/CKyJrLSClrSeqQv5BXNg2RUbPOw==
|
||||||
|
dependencies:
|
||||||
|
"@types/pug" "^2.0.4"
|
||||||
|
"@types/sass" "^1.16.0"
|
||||||
|
detect-indent "^6.0.0"
|
||||||
|
magic-string "^0.25.7"
|
||||||
|
sorcery "^0.10.0"
|
||||||
|
strip-indent "^3.0.0"
|
||||||
|
|
||||||
|
svelte@^3.49.0:
|
||||||
|
version "3.49.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.49.0.tgz#5baee3c672306de1070c3b7888fc2204e36a4029"
|
||||||
|
integrity sha512-+lmjic1pApJWDfPCpUUTc1m8azDqYCG1JN9YEngrx/hUyIcFJo6VZhj0A1Ai0wqoHcEIuQy+e9tk+4uDgdtsFA==
|
||||||
|
|
||||||
|
to-regex-range@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
|
||||||
|
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
|
||||||
|
dependencies:
|
||||||
|
is-number "^7.0.0"
|
||||||
|
|
||||||
|
tslib@^1.9.3:
|
||||||
|
version "1.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||||
|
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||||
|
|
||||||
|
tslib@^2.4.0:
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
|
||||||
|
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
|
||||||
|
|
||||||
|
typescript@*, typescript@^4.6.4:
|
||||||
|
version "4.7.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
|
||||||
|
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
||||||
|
|
||||||
|
vite@^3.0.0:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.3.tgz#c7b2ed9505a36a04be1d5d23aea4ea6fc028043f"
|
||||||
|
integrity sha512-sDIpIcl3mv1NUaSzZwiXGEy1ZoWwwC2vkxUHY6yiDacR6zf//ZFuBJrozO62gedpE43pmxnLATNR5IYUdAEkMQ==
|
||||||
|
dependencies:
|
||||||
|
esbuild "^0.14.47"
|
||||||
|
postcss "^8.4.14"
|
||||||
|
resolve "^1.22.1"
|
||||||
|
rollup "^2.75.6"
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
wheel@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/wheel/-/wheel-1.0.0.tgz#6cf46e06a854181adb8649228077f8b0d5c574ce"
|
||||||
|
integrity sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==
|
||||||
|
|
||||||
|
wrappy@1:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
|
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||||
Loading…
Add table
Reference in a new issue