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