362 lines
11 KiB
Vue
362 lines
11 KiB
Vue
<template>
|
|
<div class="svg-content">
|
|
<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="devpanel">
|
|
<div>
|
|
Current position: {{ Math.round(bbox.x) }}x{{ Math.round(bbox.y) }}
|
|
</div>
|
|
<div>
|
|
Zoom level: {{ (Math.round(bbox.z * 1000) / 1000) }}
|
|
</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";
|
|
|
|
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 panzoom = ref<null | PanZoom>(null);
|
|
const anchors = ref<SVGRectElement[]>([]);
|
|
const scrolls = ref<VideoScrollDef[]>([]);
|
|
const panToAnchor = ref();
|
|
const audioAreas = ref<AudioAreaDef[]>([]);
|
|
const bbox = reactive({
|
|
x: ref(0),
|
|
y: ref(0),
|
|
w: ref(0),
|
|
h: ref(0),
|
|
z: ref(1)
|
|
});
|
|
|
|
onMounted(async () => {
|
|
const element = root.value as unknown as HTMLDivElement;
|
|
|
|
// Fetch & load SVG
|
|
const fetchResult = await fetch(props.url);
|
|
const svgParsed = new DOMParser().parseFromString(await fetchResult.text(), "image/svg+xml") as Document;
|
|
const svg = element.appendChild(svgParsed.firstElementChild as Element) as any;
|
|
|
|
// Set document background
|
|
const pageColor = svg.getElementById("base")?.attributes.getNamedItem("pagecolor");
|
|
if (pageColor) {
|
|
emit("setBackground", pageColor.value);
|
|
}
|
|
|
|
// PanZoom
|
|
const pz = createPanZoom(element, {
|
|
smoothScroll: false,
|
|
minZoom: 0.02,
|
|
maxZoom: 3637937,
|
|
zoomSpeed: 0.05,
|
|
zoomDoubleClickSpeed: 1,
|
|
onDoubleClick: function (e) {
|
|
if (!document.fullscreenElement) {
|
|
document.body.requestFullscreen();
|
|
} else {
|
|
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;
|
|
});
|
|
|
|
function panToElement(target: SVGRectElement, smooth: boolean) {
|
|
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) {
|
|
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
|
|
);
|
|
}, 400 * 2);
|
|
} else {
|
|
pz.moveTo(
|
|
svgTargetX * -1 + window.innerWidth / 2,
|
|
svgTargetY * -1 + window.innerHeight / 2,
|
|
);
|
|
pz.zoomAbs(window.innerWidth / 2, window.innerHeight / 2, targetScale);
|
|
}
|
|
}
|
|
|
|
// Pan to start element
|
|
const start = processStart(svg);
|
|
if (start) {
|
|
panToElement(start, false);
|
|
|
|
window.addEventListener("keydown", (ev) => {
|
|
if (ev.key === " ") {
|
|
panToElement(start, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Anchors
|
|
anchors.value = processAnchors(svg);
|
|
panToAnchor.value = (anchor: SVGRectElement) => {
|
|
panToElement(anchor, true);
|
|
};
|
|
|
|
// Audio areas
|
|
audioAreas.value = processAudio(svg);
|
|
|
|
// Videoscrolls
|
|
scrolls.value = await processScrolls(svg);
|
|
|
|
// Debug Stats
|
|
let stats: Stats | undefined;
|
|
if (process.env.VUE_APP_DEMO) {
|
|
stats = new Stats();
|
|
document.body.appendChild(stats.dom);
|
|
} else {
|
|
Array.from(document.body.getElementsByClassName("dev")).forEach((el) => {
|
|
(el as HTMLElement).style.display = "none";
|
|
});
|
|
}
|
|
|
|
// Animations: FPS Counter, Edge scrolling
|
|
let mouse: MouseEvent | undefined;
|
|
window.addEventListener("mousemove", (ev) => {
|
|
mouse = ev;
|
|
});
|
|
|
|
function animate() {
|
|
if (stats) {
|
|
stats.begin();
|
|
}
|
|
|
|
// Edge scrolling
|
|
const MOVE_EDGE = 75;
|
|
const MAX_SPEED = 20;
|
|
|
|
if (document.fullscreenElement && mouse) {
|
|
let horizontalShift: number;
|
|
let verticalShift: number;
|
|
|
|
const transform = pz.getTransform();
|
|
if (mouse.clientX < MOVE_EDGE || mouse.clientX > window.innerWidth - MOVE_EDGE) {
|
|
const horizontalEdgeDistance =
|
|
(mouse.clientX < window.innerWidth / 2) ? mouse.clientX : (mouse.clientX - window.innerWidth);
|
|
const horizontalRatio = (MOVE_EDGE - Math.abs(horizontalEdgeDistance)) / MOVE_EDGE;
|
|
const direction = mouse.clientX < MOVE_EDGE ? 1 : -1;
|
|
horizontalShift = horizontalRatio * direction * MAX_SPEED;
|
|
} else {
|
|
horizontalShift = 0;
|
|
}
|
|
|
|
if (mouse.clientY < MOVE_EDGE || mouse.clientY > window.innerHeight - MOVE_EDGE) {
|
|
const verticalEdgeDistance =
|
|
(mouse.clientY < window.innerHeight / 2) ? mouse.clientY : (mouse.clientY - window.innerHeight);
|
|
const verticalRatio = (MOVE_EDGE - Math.abs(verticalEdgeDistance)) / MOVE_EDGE;
|
|
const direction = mouse.clientY < MOVE_EDGE ? 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,
|
|
panzoom,
|
|
anchors,
|
|
panToAnchor,
|
|
scrolls,
|
|
audioAreas,
|
|
bbox
|
|
};
|
|
},
|
|
});
|
|
|
|
function processAnchors(document: XMLDocument): SVGRectElement[] {
|
|
let result = [];
|
|
let i = 1;
|
|
while (true) {
|
|
let anchor = document.getElementById(`anchor_${i}`) as SVGRectElement | null;
|
|
if (anchor === null) {
|
|
break;
|
|
}
|
|
anchor.classList.add("internal");
|
|
result.push(anchor);
|
|
i++;
|
|
}
|
|
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 = el.children[0]; // to fix
|
|
const [directionString, filesURL] = descNode.textContent!.split("\n");
|
|
|
|
if (!Object.values(VideoScrollDirection).includes(directionString as VideoScrollDirection)) {
|
|
throw new Error("Unknown direction string.");
|
|
}
|
|
|
|
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,
|
|
direction: directionString as VideoScrollDirection,
|
|
files
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
function processAudio(svg: XMLDocument): AudioAreaDef[] {
|
|
const ratio = (svg as any).clientWidth / (svg as any).viewBox.baseVal.width;
|
|
|
|
return Array.from(svg.getElementsByTagName("circle"))
|
|
.filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
|
|
.map((el) => {
|
|
el.classList.add("internal");
|
|
|
|
const descNode = el.children[0]; // to fix
|
|
const audioSrc = descNode.textContent!.trim();
|
|
|
|
return {
|
|
cx: el.cx.baseVal.value,
|
|
cy: el.cy.baseVal.value,
|
|
radius: el.r.baseVal.value,
|
|
src: `content/${audioSrc}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
.devpanel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
z-index: 999;
|
|
|
|
color: white;
|
|
background: #000000aa;
|
|
border: 2px solid white;
|
|
font-family: monospace;
|
|
padding: 1em 2em;
|
|
}
|
|
</style>
|