Compare commits

...

15 commits

26 changed files with 4914 additions and 33163 deletions

View file

View file

View file

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

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Tomáš Mládek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

32924
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,72 +0,0 @@
<template>
<audio ref="audio"
:src="definition.src"
loop preload="auto"/>
</template>
<script lang="ts">
import {defineComponent, PropType, ref, watch} from "vue";
import {BoundingBox} from "@/components/SVGContent.vue";
export default defineComponent({
name: "AudioArea",
props: {
definition: {
type: Object as PropType<AudioAreaDef>,
required: true
},
bbox: {
type: Object as PropType<BoundingBox>,
required: true
},
},
setup(props) {
const audio = ref<HTMLAudioElement | null>(null);
console.debug(`[AUDIOAREA] Initializing ${props.definition.src}...`);
console.debug(props.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;
const onBBoxChange = () => {
const x = props.bbox.x + props.bbox.w / 2;
const y = props.bbox.y + props.bbox.h / 2;
const distance = Math.sqrt(Math.pow(x - props.definition.cx, 2) + Math.pow(y - props.definition.cy, 2));
if (distance < props.definition.radius) {
if (audio.value!.paused) {
console.debug(`[AUDIOAREA] Entered audio area "${props.definition.src}", starting playback...`);
audio.value!.play();
}
const volume = (props.definition.radius - distance) / props.definition.radius;
audio.value!.volume = volume * (props.bbox.z < 1 ? (props.bbox.z * vol_x + vol_b) : 1);
} else {
if (!audio.value!.paused) {
console.debug(`[AUDIOAREA] Left audio area "${props.definition.src}", pausing playback...`);
audio.value!.pause();
}
}
};
watch(props.bbox, onBBoxChange, {deep: true});
return {
audio
};
}
});
export interface AudioAreaDef {
cx: number,
cy: number,
radius: number,
src: string
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View file

@ -1,142 +0,0 @@
<template>
<div class="video-scroll" ref="root">
<img class="visible displayed loaded"
:src="definition.files[0]"
:style="{
top: `${Math.round(definition.top)}px`,
left: `${Math.round(definition.left)}px`,
width: isHorizontal ? `${Math.round(definition.width)}px` : 'auto',
height: isVertical ? `${Math.round(definition.height)}px` : 'auto',
transform: `rotate(${definition.angle}deg)`
}"
/>
<!--suppress RequiredAttributes -->
<img v-for="file in dynamicFiles"
:data-src="file.src"
:style="{
top: `${Math.round(file.top)}px`,
left: `${Math.round(file.left)}px`,
width: `${Math.round(definition.width)}px`,
height: `${Math.round(definition.height)}px`,
transform: `rotate(${definition.angle}deg)`
}"
/>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import {rotate} from "@/utils";
export default defineComponent({
name: "VideoScroll",
props: {
definition: {
type: Object as PropType<VideoScrollDef>,
required: true
}
},
computed: {
dynamicFiles(): { top: number, left: number, src: string }[] {
return this.definition.files.slice(1).map((src: string, idx: number) => {
const cy = this.definition.top +
(this.isVertical ? (this.definition.height * (idx + 1) * this.verticalDirection) : 0);
const cx = this.definition.left +
(this.isHorizontal ? (this.definition.width * (idx + 1) * this.horizontalDirection) : 0);
const [left, top] = rotate(cx, cy, this.definition.left, this.definition.top, this.definition.angle);
return {top, left, src};
});
},
isHorizontal(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) => dir === VideoScrollDirection.LEFT || dir === VideoScrollDirection.RIGHT
);
},
isVertical(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) => dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
);
},
horizontalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.RIGHT) ? 1 : -1;
},
verticalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.DOWN) ? 1 : -1;
}
},
mounted() {
const observer = new IntersectionObserver((entries, _) => {
entries.forEach((entry) => {
const element = entry.target as HTMLImageElement;
if (entry.isIntersecting) {
element.classList.add("visible");
if (!element.src) {
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 (this.isHorizontal) {
element.style.height = "auto";
} else {
element.style.width = "auto";
}
};
}
} else {
element.classList.remove("visible");
}
});
});
Array.from((this.$refs.root as Element).children).forEach((el) => {
observer.observe(el);
});
}
});
export enum VideoScrollDirection {
RIGHT = "right",
LEFT = "left",
UP = "up",
DOWN = "down"
}
export interface VideoScrollDef {
top: number,
left: number,
angle: number,
width: number,
height: number,
directions: VideoScrollDirection[],
files: string[]
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.video-scroll img {
position: absolute;
image-rendering: optimizeSpeed;
background: grey;
visibility: hidden;
opacity: 0;
transition: opacity .5s;
}
.video-scroll img.visible {
visibility: visible !important;
}
.video-scroll img.displayed {
opacity: 1 !important;
}
.video-scroll img.loaded {
background: transparent !important;
}
</style>

View file

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

3894
bun.lock Normal file

File diff suppressed because it is too large Load diff

0
content/.gitkeep Normal file
View file

572
lint_intro.ts Normal file
View file

@ -0,0 +1,572 @@
#!/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();

View file

@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
"serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve",
"build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build"
},
"dependencies": {
"@vue/cli": "^4.5.12",
@ -17,12 +17,16 @@
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@types/stats.js": "^0.17.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@xmldom/xmldom": "^0.9.8",
"commander": "^14.0.0",
"tsx": "^4.20.3",
"typescript": "~3.9.3"
}
}

View file

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

View file

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

View file

@ -5,10 +5,19 @@
</div>
<div class="content" ref="root">
<div class="video-scrolls">
<VideoScroll v-for="scroll in scrolls" :definition="scroll" />
<VideoScroll
v-for="scroll in scrolls"
:definition="scroll"
:key="scroll.id"
/>
</div>
</div>
<AudioArea v-for="audio in audioAreas" :definition="audio" :bbox="bbox" />
<AudioArea
v-for="audio in audioAreas"
:definition="audio"
:bbox="bbox"
:key="audio.id"
/>
<div class="dev devpanel">
<div>
<span>Current viewport position:</span>
@ -382,10 +391,10 @@ export default defineComponent({
if (gp) {
if (gp.buttons[7].pressed) {
gamePadZoomSpeed += .1;
gamePadZoomSpeed += 0.1;
}
if (gp.buttons[5].pressed) {
gamePadZoomSpeed -= .1;
gamePadZoomSpeed -= 0.1;
}
if (gamePadZoomSpeed < 1) {
gamePadZoomSpeed = 1;
@ -469,10 +478,14 @@ async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
direction as VideoScrollDirection
)
) {
throw new Error(`Unknown direction string: "${direction}"`);
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}`);
@ -500,6 +513,7 @@ async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
}
return {
id: el.id,
top: y * ratio,
left: x * ratio,
angle,
@ -540,6 +554,7 @@ function processAudio(svg: XMLDocument): AudioAreaDef[] {
el.classList.add("internal");
return {
id: el.id,
cx: el.cx.baseVal.value,
cy: el.cy.baseVal.value,
radius,

View file

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

View file

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

6
vue.config.js Normal file
View file

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