Compare commits
No commits in common. "main" and "vue-legacy" have entirely different histories.
main
...
vue-legacy
26 changed files with 33163 additions and 4914 deletions
|
@ -6,26 +6,20 @@ build site:
|
||||||
image: node:lts
|
image: node:lts
|
||||||
stage: build
|
stage: build
|
||||||
script:
|
script:
|
||||||
- npm ci --cache .npm --prefer-offline
|
- cd app
|
||||||
|
- npm install --progress=false
|
||||||
- npm run build
|
- npm run build
|
||||||
cache:
|
|
||||||
key: ${CI_COMMIT_REF_SLUG}
|
|
||||||
paths:
|
|
||||||
- .npm
|
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- dist
|
- app/dist
|
||||||
|
|
||||||
deploy site:
|
deploy site:
|
||||||
image: instrumentisto/rsync-ssh
|
image: instrumentisto/rsync-ssh
|
||||||
stage: deploy
|
stage: deploy
|
||||||
only:
|
|
||||||
refs:
|
|
||||||
- master
|
|
||||||
script:
|
script:
|
||||||
- mkdir ~/.ssh
|
- mkdir ~/.ssh
|
||||||
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
- chmod 644 ~/.ssh/known_hosts
|
- chmod 644 ~/.ssh/known_hosts
|
||||||
- eval $(ssh-agent -s)
|
- eval $(ssh-agent -s)
|
||||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
||||||
- rsync -vr -e "ssh -p ${SSH_PORT}" dist/ "${DEPLOY_DEST}"
|
- rsync -vr -e "ssh -p ${SSH_PORT}" app/dist/ "${DEPLOY_DEST}"
|
||||||
|
|
22
LICENSE
22
LICENSE
|
@ -1,22 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
0
.gitignore → app/.gitignore
vendored
0
.gitignore → app/.gitignore
vendored
32924
app/package-lock.json
generated
Normal file
32924
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -3,8 +3,8 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build"
|
"build": "vue-cli-service build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/cli": "^4.5.12",
|
"@vue/cli": "^4.5.12",
|
||||||
|
@ -17,16 +17,12 @@
|
||||||
"vuex": "^4.0.0-0"
|
"vuex": "^4.0.0-0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
|
||||||
"@types/stats.js": "^0.17.0",
|
"@types/stats.js": "^0.17.0",
|
||||||
"@vue/cli-plugin-babel": "~4.5.0",
|
"@vue/cli-plugin-babel": "~4.5.0",
|
||||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||||
"@vue/cli-service": "~4.5.0",
|
"@vue/cli-service": "~4.5.0",
|
||||||
"@vue/compiler-sfc": "^3.0.0",
|
"@vue/compiler-sfc": "^3.0.0",
|
||||||
"@xmldom/xmldom": "^0.9.8",
|
|
||||||
"commander": "^14.0.0",
|
|
||||||
"tsx": "^4.20.3",
|
|
||||||
"typescript": "~3.9.3"
|
"typescript": "~3.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,9 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<SVGContent
|
<SVGContent id="root" url="content/intro.svg" @set-background="setBackground"/>
|
||||||
id="root"
|
|
||||||
url="content/intro.svg"
|
|
||||||
@set-background="setBackground"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -14,27 +10,23 @@ import "normalize.css";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "App",
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
SVGContent,
|
SVGContent
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setBackground(background: string) {
|
setBackground(background: string) {
|
||||||
document.body.style.background = background;
|
document.body.style.background = background;
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html,
|
html, body {
|
||||||
body {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: black;
|
background: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html, body, #app, #root {
|
||||||
body,
|
|
||||||
#app,
|
|
||||||
#root {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: default;
|
cursor: default;
|
72
app/src/components/AudioArea.vue
Normal file
72
app/src/components/AudioArea.vue
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<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>
|
|
@ -5,19 +5,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="content" ref="root">
|
<div class="content" ref="root">
|
||||||
<div class="video-scrolls">
|
<div class="video-scrolls">
|
||||||
<VideoScroll
|
<VideoScroll v-for="scroll in scrolls" :definition="scroll" />
|
||||||
v-for="scroll in scrolls"
|
|
||||||
:definition="scroll"
|
|
||||||
:key="scroll.id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AudioArea
|
<AudioArea v-for="audio in audioAreas" :definition="audio" :bbox="bbox" />
|
||||||
v-for="audio in audioAreas"
|
|
||||||
:definition="audio"
|
|
||||||
:bbox="bbox"
|
|
||||||
:key="audio.id"
|
|
||||||
/>
|
|
||||||
<div class="dev devpanel">
|
<div class="dev devpanel">
|
||||||
<div>
|
<div>
|
||||||
<span>Current viewport position:</span>
|
<span>Current viewport position:</span>
|
||||||
|
@ -391,10 +382,10 @@ export default defineComponent({
|
||||||
|
|
||||||
if (gp) {
|
if (gp) {
|
||||||
if (gp.buttons[7].pressed) {
|
if (gp.buttons[7].pressed) {
|
||||||
gamePadZoomSpeed += 0.1;
|
gamePadZoomSpeed += .1;
|
||||||
}
|
}
|
||||||
if (gp.buttons[5].pressed) {
|
if (gp.buttons[5].pressed) {
|
||||||
gamePadZoomSpeed -= 0.1;
|
gamePadZoomSpeed -= .1;
|
||||||
}
|
}
|
||||||
if (gamePadZoomSpeed < 1) {
|
if (gamePadZoomSpeed < 1) {
|
||||||
gamePadZoomSpeed = 1;
|
gamePadZoomSpeed = 1;
|
||||||
|
@ -478,14 +469,10 @@ async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
|
||||||
direction as VideoScrollDirection
|
direction as VideoScrollDirection
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
console.error(
|
throw new Error(`Unknown direction string: "${direction}"`);
|
||||||
`Unknown direction definition: "${direction}" (in #${el.id})`
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return direction as VideoScrollDirection;
|
return direction as VideoScrollDirection;
|
||||||
})
|
});
|
||||||
.filter((d) => Boolean(d)) as VideoScrollDirection[];
|
|
||||||
|
|
||||||
console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
|
console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
|
||||||
const fileFetch = await fetch(`content/${filesURL}`);
|
const fileFetch = await fetch(`content/${filesURL}`);
|
||||||
|
@ -513,7 +500,6 @@ async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: el.id,
|
|
||||||
top: y * ratio,
|
top: y * ratio,
|
||||||
left: x * ratio,
|
left: x * ratio,
|
||||||
angle,
|
angle,
|
||||||
|
@ -554,7 +540,6 @@ function processAudio(svg: XMLDocument): AudioAreaDef[] {
|
||||||
el.classList.add("internal");
|
el.classList.add("internal");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: el.id,
|
|
||||||
cx: el.cx.baseVal.value,
|
cx: el.cx.baseVal.value,
|
||||||
cy: el.cy.baseVal.value,
|
cy: el.cy.baseVal.value,
|
||||||
radius,
|
radius,
|
142
app/src/components/VideoScroll.vue
Normal file
142
app/src/components/VideoScroll.vue
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
<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>
|
0
src/shims-vue.d.ts → app/src/shims-vue.d.ts
vendored
0
src/shims-vue.d.ts → app/src/shims-vue.d.ts
vendored
6
app/vue.config.js
Normal file
6
app/vue.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
publicPath: '/las/',
|
||||||
|
devServer: {
|
||||||
|
hot: false
|
||||||
|
}
|
||||||
|
};
|
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();
|
|
|
@ -1,108 +0,0 @@
|
||||||
<template>
|
|
||||||
<audio ref="audio" :src="audioSrc" loop preload="auto" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent, PropType, ref, watch } from "vue";
|
|
||||||
import { BoundingBox } from "@/components/SVGContent.vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "AudioArea",
|
|
||||||
props: {
|
|
||||||
definition: {
|
|
||||||
type: Object as PropType<AudioAreaDef>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
bbox: {
|
|
||||||
type: Object as PropType<BoundingBox>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const audio = ref<HTMLAudioElement | null>(null);
|
|
||||||
const audioSrc = ref<string>(""); // Ref to hold audio source after preloading
|
|
||||||
const isPreloaded = ref<boolean>(false);
|
|
||||||
|
|
||||||
console.debug(`[AUDIOAREA] Initializing ${props.definition.src}...`);
|
|
||||||
console.debug(props.definition);
|
|
||||||
|
|
||||||
// Preload the audio file completely to avoid keeping connections open
|
|
||||||
const preloadAudio = async (src: string) => {
|
|
||||||
console.debug(`[AUDIOAREA] Preloading audio: ${src}`);
|
|
||||||
try {
|
|
||||||
// Fetch the entire audio file
|
|
||||||
const response = await fetch(src);
|
|
||||||
if (!response.ok) throw new Error(`Failed to load audio: ${response.statusText}`);
|
|
||||||
|
|
||||||
// Convert to blob to ensure full download
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Create a blob URL to use as the audio source
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
audioSrc.value = blobUrl;
|
|
||||||
isPreloaded.value = true;
|
|
||||||
console.debug(`[AUDIOAREA] Successfully preloaded audio: ${src}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[AUDIOAREA] Error preloading audio: ${error}`);
|
|
||||||
// Fall back to original source if preloading fails
|
|
||||||
audioSrc.value = src;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start preloading when component is created
|
|
||||||
preloadAudio(props.definition.src);
|
|
||||||
|
|
||||||
const MIN_SCALE = 0.02;
|
|
||||||
const MIN_VOLUME_MULTIPLIER = 0.33;
|
|
||||||
const vol_x = (1 - MIN_VOLUME_MULTIPLIER) / (1 - MIN_SCALE);
|
|
||||||
const vol_b = 1 - vol_x;
|
|
||||||
|
|
||||||
const onBBoxChange = () => {
|
|
||||||
const x = props.bbox.x + props.bbox.w / 2;
|
|
||||||
const y = props.bbox.y + props.bbox.h / 2;
|
|
||||||
const distance = Math.sqrt(
|
|
||||||
Math.pow(x - props.definition.cx, 2) +
|
|
||||||
Math.pow(y - props.definition.cy, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance < props.definition.radius) {
|
|
||||||
if (audio.value!.paused) {
|
|
||||||
console.debug(
|
|
||||||
`[AUDIOAREA] Entered audio area "${props.definition.src}", starting playback...`
|
|
||||||
);
|
|
||||||
audio.value!.play();
|
|
||||||
}
|
|
||||||
const volume =
|
|
||||||
(props.definition.radius - distance) / props.definition.radius;
|
|
||||||
audio.value!.volume =
|
|
||||||
volume * (props.bbox.z < 1 ? props.bbox.z * vol_x + vol_b : 1);
|
|
||||||
} else {
|
|
||||||
if (!audio.value!.paused) {
|
|
||||||
console.debug(
|
|
||||||
`[AUDIOAREA] Left audio area "${props.definition.src}", pausing playback...`
|
|
||||||
);
|
|
||||||
audio.value!.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
watch(props.bbox, onBBoxChange, { deep: true });
|
|
||||||
|
|
||||||
return {
|
|
||||||
audio,
|
|
||||||
audioSrc,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface AudioAreaDef {
|
|
||||||
id: string;
|
|
||||||
cx: number;
|
|
||||||
cy: number;
|
|
||||||
radius: number;
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
|
@ -1,180 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="video-scroll" ref="root" v-if="definition.directions.length > 0">
|
|
||||||
<img
|
|
||||||
class="visible displayed loaded"
|
|
||||||
:src="definition.files[0]"
|
|
||||||
:style="{
|
|
||||||
top: `${Math.round(definition.top)}px`,
|
|
||||||
left: `${Math.round(definition.left)}px`,
|
|
||||||
width: isHorizontal ? `${Math.round(definition.width)}px` : 'auto',
|
|
||||||
height: isVertical ? `${Math.round(definition.height)}px` : 'auto',
|
|
||||||
transform: `rotate(${definition.angle}deg)`,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!--suppress RequiredAttributes -->
|
|
||||||
<img
|
|
||||||
v-for="(file, idx) in dynamicFiles"
|
|
||||||
:key="`${idx}_${file.src}`"
|
|
||||||
:data-src="file.src"
|
|
||||||
:style="{
|
|
||||||
top: `${Math.round(file.top)}px`,
|
|
||||||
left: `${Math.round(file.left)}px`,
|
|
||||||
width: `${Math.round(definition.width)}px`,
|
|
||||||
height: `${Math.round(definition.height)}px`,
|
|
||||||
transform: `rotate(${definition.angle}deg)`,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent, PropType } from "vue";
|
|
||||||
import { rotate } from "@/utils";
|
|
||||||
import { queueImageForLoading } from "@/services/ImageLoader";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "VideoScroll",
|
|
||||||
props: {
|
|
||||||
definition: {
|
|
||||||
type: Object as PropType<VideoScrollDef>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
dynamicFiles(): { top: number; left: number; src: string }[] {
|
|
||||||
return this.definition.files.slice(1).map((src: string, idx: number) => {
|
|
||||||
const cy =
|
|
||||||
this.definition.top +
|
|
||||||
(this.isVertical
|
|
||||||
? this.definition.height * (idx + 1) * this.verticalDirection
|
|
||||||
: 0);
|
|
||||||
const cx =
|
|
||||||
this.definition.left +
|
|
||||||
(this.isHorizontal
|
|
||||||
? this.definition.width * (idx + 1) * this.horizontalDirection
|
|
||||||
: 0);
|
|
||||||
const [left, top] = rotate(
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
this.definition.left,
|
|
||||||
this.definition.top,
|
|
||||||
this.definition.angle
|
|
||||||
);
|
|
||||||
return { top, left, src };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isHorizontal(): boolean {
|
|
||||||
return this.definition.directions.some(
|
|
||||||
(dir: VideoScrollDirection) =>
|
|
||||||
dir === VideoScrollDirection.LEFT ||
|
|
||||||
dir === VideoScrollDirection.RIGHT
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isVertical(): boolean {
|
|
||||||
return this.definition.directions.some(
|
|
||||||
(dir: VideoScrollDirection) =>
|
|
||||||
dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
|
|
||||||
);
|
|
||||||
},
|
|
||||||
horizontalDirection(): number {
|
|
||||||
return this.definition.directions.includes(VideoScrollDirection.RIGHT)
|
|
||||||
? 1
|
|
||||||
: -1;
|
|
||||||
},
|
|
||||||
verticalDirection(): number {
|
|
||||||
return this.definition.directions.includes(VideoScrollDirection.DOWN)
|
|
||||||
? 1
|
|
||||||
: -1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleImageLoad(element: HTMLImageElement) {
|
|
||||||
// Setup image display when loaded
|
|
||||||
element.classList.add("displayed");
|
|
||||||
element.classList.add("loaded");
|
|
||||||
|
|
||||||
// Adjust dimensions based on scroll direction
|
|
||||||
if (this.isHorizontal) {
|
|
||||||
element.style.height = "auto";
|
|
||||||
} else {
|
|
||||||
element.style.width = "auto";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const observer = new IntersectionObserver((entries, _) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const element = entry.target as HTMLImageElement;
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
element.classList.add("visible");
|
|
||||||
if (!element.src && element.dataset.src) {
|
|
||||||
// Queue the image for loading through the global service
|
|
||||||
const self = this;
|
|
||||||
queueImageForLoading(element, function() {
|
|
||||||
self.handleImageLoad(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a fallback to show the image after a timeout even if not fully loaded
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!element.classList.contains("loaded")) {
|
|
||||||
element.classList.add("displayed");
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
element.classList.remove("visible");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.$refs.root) {
|
|
||||||
Array.from((this.$refs.root as Element).children).forEach((el) => {
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export enum VideoScrollDirection {
|
|
||||||
RIGHT = "right",
|
|
||||||
LEFT = "left",
|
|
||||||
UP = "up",
|
|
||||||
DOWN = "down",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoScrollDef {
|
|
||||||
id: string;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
angle: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
directions: VideoScrollDirection[];
|
|
||||||
files: string[];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style>
|
|
||||||
.video-scroll img {
|
|
||||||
position: absolute;
|
|
||||||
image-rendering: optimizeSpeed;
|
|
||||||
background: grey;
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-scroll img.visible {
|
|
||||||
visibility: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-scroll img.displayed {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-scroll img.loaded {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,80 +0,0 @@
|
||||||
/**
|
|
||||||
* Global image loading queue service to prevent hitting browser connection limits
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const MAX_CONCURRENT_LOADS = 5;
|
|
||||||
|
|
||||||
// State
|
|
||||||
let activeLoads = 0;
|
|
||||||
const imageQueue: Array<{
|
|
||||||
element: HTMLImageElement;
|
|
||||||
onComplete: () => void;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue an image for loading, respecting the global concurrent loading limit
|
|
||||||
*/
|
|
||||||
export function queueImageForLoading(
|
|
||||||
element: HTMLImageElement,
|
|
||||||
onComplete?: () => void
|
|
||||||
) {
|
|
||||||
if (!element.dataset.src) {
|
|
||||||
console.warn("[ImageLoader] Element has no data-src attribute");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
imageQueue.push({
|
|
||||||
element,
|
|
||||||
onComplete: onComplete || (() => {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to process queue
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process the next items in the queue if we have capacity
|
|
||||||
*/
|
|
||||||
function processQueue() {
|
|
||||||
// Load more images if we have capacity and images in the queue
|
|
||||||
while (activeLoads < MAX_CONCURRENT_LOADS && imageQueue.length > 0) {
|
|
||||||
const next = imageQueue.shift();
|
|
||||||
if (next) {
|
|
||||||
loadImage(next.element, next.onComplete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal function to handle the actual image loading
|
|
||||||
*/
|
|
||||||
function loadImage(element: HTMLImageElement, onComplete: () => void) {
|
|
||||||
// Increment active loads counter
|
|
||||||
activeLoads++;
|
|
||||||
|
|
||||||
const src = element.dataset.src;
|
|
||||||
console.debug(`[ImageLoader] Loading ${src}`);
|
|
||||||
|
|
||||||
// Start loading the image
|
|
||||||
element.src = src!;
|
|
||||||
|
|
||||||
// Handle load completion
|
|
||||||
const handleCompletion = () => {
|
|
||||||
activeLoads--;
|
|
||||||
onComplete();
|
|
||||||
processQueue();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set handlers
|
|
||||||
element.onload = () => {
|
|
||||||
console.debug(`[ImageLoader] Loaded ${src}`);
|
|
||||||
handleCompletion();
|
|
||||||
};
|
|
||||||
|
|
||||||
element.onerror = () => {
|
|
||||||
console.error(`[ImageLoader] Failed to load ${src}`);
|
|
||||||
handleCompletion();
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
// publicPath: process.env.VUE_APP_BASE_URL || '/las/',
|
|
||||||
devServer: {
|
|
||||||
hot: false,
|
|
||||||
},
|
|
||||||
};
|
|
Loading…
Add table
Reference in a new issue