Compare commits
15 commits
vue-legacy
...
main
Author | SHA1 | Date | |
---|---|---|---|
2a473e6b67 | |||
0afb320728 | |||
5f410715ca | |||
c3bae1801b | |||
7fd77e95be | |||
b6e6d3e23b | |||
e9516556e2 | |||
0eefaa7f44 | |||
7bff008853 | |||
bfa569853b | |||
50b9883f99 | |||
bfc364fe4b | |||
85e6fc7670 | |||
88dcb89ca7 | |||
b6fcd09a3b |
26 changed files with 4914 additions and 33163 deletions
0
app/.gitignore → .gitignore
vendored
0
app/.gitignore → .gitignore
vendored
|
@ -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
22
LICENSE
Normal 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
32924
app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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>
|
|
@ -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>
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
publicPath: '/las/',
|
||||
devServer: {
|
||||
hot: false
|
||||
}
|
||||
};
|
0
content/.gitkeep
Normal file
0
content/.gitkeep
Normal file
572
lint_intro.ts
Normal file
572
lint_intro.ts
Normal 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();
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
108
src/components/AudioArea.vue
Normal file
108
src/components/AudioArea.vue
Normal 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>
|
|
@ -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,
|
180
src/components/VideoScroll.vue
Normal file
180
src/components/VideoScroll.vue
Normal 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>
|
80
src/services/ImageLoader.ts
Normal file
80
src/services/ImageLoader.ts
Normal 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();
|
||||
};
|
||||
}
|
0
app/src/shims-vue.d.ts → src/shims-vue.d.ts
vendored
0
app/src/shims-vue.d.ts → src/shims-vue.d.ts
vendored
6
vue.config.js
Normal file
6
vue.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
// publicPath: process.env.VUE_APP_BASE_URL || '/las/',
|
||||
devServer: {
|
||||
hot: false,
|
||||
},
|
||||
};
|
Loading…
Add table
Reference in a new issue