This commit is contained in:
2024-07-15 21:54:12 +00:00
commit 41bdd55358
33 changed files with 4503 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
<template>
<div>
<div style="background-color: white; display: flex; flex-direction: row;gap: 0.5rem;padding: 0.5rem">
<div style="max-width: 32px; height: 32px; overflow: hidden;">
<google-cast-launcher style="--disconnected-color: black;" ></google-cast-launcher>
</div>
<button @click="openMapWindow">Open map window</button>
<button @click="onStreamImage">Stream image</button>
</div>
<fieldset>
<legend>Image settings</legend>
<label>Width</label>
<input type="number" v-model="imageWidth" />
<label>Screen Diameter</label>
<input type="number" v-model="screenDiameterInch" />
<div>DPI: {{ screenDPI }}</div>
<label>Flip <input type="checkbox" v-model="flipped"></label>
<label>Position (X/Y)</label>
<input type="range" min="0" max="100" v-model="positionX" />
<input type="range" min="0" max="100" v-model="positionY" />
<button @click="positionX = 50;positionY = 50">center</button>
</fieldset>
<FileManager @openFile="openFile" />
</div>
</template>
<script setup lang="ts">
import FileManager from '../../components/FileManager/FileManager.vue';
import type { FileWithMeta } from '../../model/FileManager';
import {ref, inject, watch} from 'vue';
import {SenderSymbol} from '../../model/injectionSymbols';
const openedFile = ref<FileWithMeta>()
const sender = inject(SenderSymbol);
const imageWidth = ref(20);
const screenDiameterInch = ref(65);
const flipped = ref(false);
const positionX = ref(50);
const positionY = ref(50);
watch(() => imageWidth.value, (value) => {
sender?.sendMessage({
type: 'changeWidth',
width: value
})
});
watch(() => screenDiameterInch.value, (value) => {
sender?.sendMessage({
type: 'changeDiameter',
diameter: value
})
});
watch(() => [positionX.value, positionY.value, flipped.value], (value) => {
sender?.sendMessage({
type: 'changePosition',
x: positionX.value,
y: positionY.value,
flipped: flipped.value,
})
});
function openFile(file: FileWithMeta) {
sender?.sendMessage({
type: 'showFile',
file,
})
imageWidth.value = file.width ?? 20;
openedFile.value = file;
}
function openMapWindow() {
window.open('/map/', 'map');
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function onStreamImage() {
const image = new Image()
image.onload = async () => {
const canvas = document.createElement('canvas');
canvas.setAttribute('willReadFrequently', 'true');
canvas.width = image.width;
canvas.height = image.height;
const [maxWidth, maxHeight] = (image.width > image.height) ? [3840, 2160] : [2160, 3840];
if (image.width > maxWidth || image.height > maxHeight) {
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
canvas.width = image.width * ratio;
canvas.height = image.height * ratio;
}
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const chunkSize = {width: 128, height: 128};
for (let x = 0; x < canvas.width; x += chunkSize.width) {
for (let y = 0; y < canvas.height; y += chunkSize.height) {
const imageData = ctx.getImageData(x, y, chunkSize.width, chunkSize.height);
sender?.sendMessage({
type: 'streamImage',
imageData: {
data: [...new Uint32Array(imageData.data.buffer)],
width: imageData.width,
height: imageData.height,
},
canvas: {
width: canvas.width,
height: canvas.height,
},
x,
y,
}, {webRTC: true});
await delay(100);
}
}
};
image.src = openedFile.value?.path;
}
</script>
<style>
.aside {
position: fixed;
overflow: auto;
width: 300px;
height: 100vh;
overflow: auto;
right:0;
background: rgba(0,0,0,0.8);
border-left: #555 2px solid;
backdrop-filter: blur(5px);
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div>
<fieldset>
<legend>Image settings</legend>
<label>Width</label>
<input type="number" v-model="imageWidth" />
<label>Screen Diameter</label>
<input type="number" v-model="screenDiameterInch" />
<div>DPI: {{ screenDPI }}</div>
<div style="max-width: 32px; height: 32px; overflow: hidden;">
<google-cast-launcher style="max-width: 2rem" ></google-cast-launcher>
</div>
</fieldset>
<FileManager @openFile="openFile" />
</div>
</template>
<script setup lang="ts">
import FileManager from '../../components/FileManager/FileManager.vue';
import type { FileWithMeta } from '../../model/FileManager';
import {ref, computed, inject, watch} from 'vue';
import {CastTypeSymbol, CastReceiverSymbol, CastSenderSymbol} from '../../model/injectionSymbols';
const openedFile = ref<FileWithMeta>()
const castType = inject(CastTypeSymbol);
const receiver = inject(CastReceiverSymbol);
const sender = inject(CastSenderSymbol);
const canvas = ref<HTMLCanvasElement>();
const imageWidth = ref(20);
const screenDiameterInch = ref(65);
const screenDPI = computed(() => {
const diaPx = Math.sqrt(window.screen.width**2 + window.screen.height ** 2);
return diaPx / screenDiameterInch.value;
})
watch(() => imageWidth.value, (value) => {
if (sender) {
sender.sendMessage({
type: 'imageWidth',
imageWidth: value
})
}
});
function openFile(file: FileWithMeta) {
console.log('open file', file)
openedFile.value = file
imageWidth.value = file.width ?? 20;
if (sender) {
sender.sendMessage({
type: 'loadMap',
file
})
const img = new Image();
img.onload = () => {
canvas.value!.width = img.naturalWidth;
canvas.value!.height = img.naturalHeight;
const ctx = canvas.value!.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const msgs = [];
const chunkWidth = 32;
const chunkHeight = 32;
for (let x = 0; x< 1920; x += chunkWidth) {
for (let y = 0; y < 1080; y += chunkHeight) {
const data = ctx.getImageData(x, y, chunkWidth, chunkHeight);
const msg = {
type: 'imageData',
imageData: {
data: [...data.data],
width: data.width,
height: data.height,
x,
y,
},
};
msgs.push(msg);
sender.sendMessage(msg);
}
}
ctx.clearRect(0, 0, img.naturalWidth, img.naturalHeight);
for (const msg of msgs) {
loadImageData(msg);
}
};
img.src = file.path;
}
}
function loadImageData(detail) {
const imageData = new ImageData(
new Uint8ClampedArray(detail.imageData.data),
detail.imageData.width,
detail.imageData.height
)
const ctx = canvas.value!.getContext('2d')!;
ctx.putImageData(imageData, detail.imageData.x, detail.imageData.y);
}
if (receiver) {
receiver.addEventListener('message', ({detail}) => {
if (detail.type === 'loadMap') {
openFile(detail.file);
}
if (detail.type === 'imageWidth') {
imageWidth.value = detail.imageWidth;
}
if (detail.type === 'imageData') {
loadImageData(detail);
}
});
}
</script>
<style>
.aside {
position: fixed;
overflow: auto;
width: 300px;
height: 100vh;
overflow: auto;
right:0;
background: rgba(0,0,0,0.8);
border-left: #555 2px solid;
backdrop-filter: blur(5px);
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div class="map-app">
<MapScene
ref="map"
:src="src"
:dpi="screenDPI"
:flipped="flipped"
:width="imageWidth"
:position="{x: positionX, y: positionY}"
/>
</div>
</template>
<script setup lang="ts">
import MapScene from './MapScene.vue';
import {inject, ref, computed} from 'vue';
import { ReceiverSymbol } from '../../model/injectionSymbols';
const imageWidth = ref(20);
const screenDiameterInch = ref(65);
const positionX = ref(50);
const positionY = ref(50);
const flipped = ref(false);
const src = ref<string>()
const map = ref<typeof MapScene>();
const screenDPI = computed(() => {
const diaPx = Math.sqrt(window.screen.width**2 + window.screen.height ** 2);
return diaPx / screenDiameterInch.value;
})
const receiver = inject(ReceiverSymbol);
receiver?.addEventListener('changeDiameter', ({detail}) => {
screenDiameterInch.value = detail.diameter;
});
receiver?.addEventListener('changeWidth', ({detail}) => {
imageWidth.value = detail.width;
});
receiver?.addEventListener('showFile', ({detail}) => {
src.value = detail.file.path;
if (detail.file.width) {
imageWidth.value = detail.file.width;
}
});
receiver?.addEventListener('changePosition', ({detail}) => {
positionX.value = detail.x;
positionY.value = detail.y;
flipped.value = detail.flipped;
});
</script>
<style>
.map-app {
position: fixed;
inset: 0;
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<video
v-if="type === 'video'"
:src="src"
:style="dynamicStyle"
class="map-scene--video map-scene"
ref="video"
@loadedmetadata="onVideoLoaded"
autoplay
loop
muted
></video>
<canvas
v-else
:style="dynamicStyle"
ref="canvas"
class="map-scene--image map-scene"
/>
</template>
<script setup lang="ts">
import { computed, defineProps, inject, ref, watch } from 'vue';
import {ReceiverSymbol} from '../../model/injectionSymbols';
const video = ref<HTMLVideoElement>();
const image = ref<HTMLImageElement>();
const canvas = ref<HTMLCanvasElement>();
const props = defineProps<{
src?: string;
width: number;
dpi: number;
position: {x: number, y: number};
flipped: boolean;
}>();
const isImage = computed(() => props.src?.endsWith('.png') || props.src?.endsWith('.jpg') || props.src?.endsWith('.jpeg') || props.src?.endsWith('.webp'));
const isVideo = computed(() => props.src?.endsWith('.mp4') || props.src?.endsWith('.webm'));
const mediaDimensions = ref({width: 0, height: 0});
const virtualMediaDimensions = ref({width: 0, height: 0});
const windowSize = {
width: window.innerWidth,
height: window.innerHeight,
};
watch(() => props.src, (src) => {
if (isImage.value) {
const img = new Image();
img.src = src!;
img.onload = () => {
mediaDimensions.value = {
width: img.naturalWidth,
height: img.naturalHeight,
};
canvas.value!.width = img.naturalWidth;
canvas.value!.height = img.naturalHeight;
const ctx = canvas.value!.getContext('2d')!;
ctx.drawImage(img, 0, 0);
};
} else if (isVideo.value) {
video.value!.src = src!;
}
});
const receiver = inject(ReceiverSymbol);
receiver?.addEventListener('streamImage', ({detail}) => {
if (canvas.value) {
if (canvas.value.width !== detail.canvas.width || canvas.value.height !== detail.canvas.height) {
const backup = document.createElement('canvas');
backup.width = canvas.value.width;
backup.height = canvas.value.height;
const backupCtx = backup.getContext('2d')!;
backupCtx.drawImage(canvas.value, 0, 0);
canvas.value.width = detail.canvas.width;
canvas.value.height = detail.canvas.height;
const ctx = canvas.value.getContext('2d')!;
ctx.drawImage(backup, 0, 0, detail.canvas.width, detail.canvas.height);
}
const imageData = new ImageData(
new Uint8ClampedArray(new Uint32Array(detail.imageData.data).buffer),
detail.imageData.width,
detail.imageData.height
);
const ctx = canvas.value.getContext('2d')!;
ctx.putImageData(imageData, detail.x, detail.y);
}
});
const type = computed(() => {
if (isImage.value) {
return 'image';
} else if (isVideo.value) {
return 'video';
} else {
return 'unknown';
}
});
const dynamicStyle = computed(() => {
const width = props.dpi * props.width;
return {
'--width': `${width}px`,
'--height': `${(width) * (mediaDimensions.value.height / mediaDimensions.value.width)}px`,
'--x': `${props.position.x/100}`,
'--y': `${props.position.y/100}`,
'--rotate': mediaDimensions.value.width > mediaDimensions.value.height ? (props.flipped ? '180deg' : '0deg') :
(props.flipped ? '90deg' : '-90deg'),
};
});
function onImageLoaded(event: Event) {
console.log('image loaded', event.target);
mediaDimensions.value = {
width: image.value!.naturalWidth,
height: image.value!.naturalHeight,
};
}
function onVideoLoaded(event: Event) {
console.log('video loaded', event.target);
mediaDimensions.value = {
width: video.value!.videoWidth,
height: video.value!.videoHeight,
};
}
function updateImageSize() {
if (image.value) {
image.value.width = props.width;
}
}
</script>
<style>
.map-scene {
position: absolute;
left: calc(var(--x) * 100vw - var(--width) / 2);
top: calc(var(--y) * 100vh - var(--height) / 2);
width: var(--width);
transform: rotate(var(--rotate));
transform-origin: center;
}
</style>