Initial boilerplate scaffold for continuum-app
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||
|
||||
interface Printer {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
farmId: string;
|
||||
}
|
||||
|
||||
interface LiveTelemetry {
|
||||
state: "idle" | "printing" | "paused" | "error" | "offline";
|
||||
nozzleTempC: number;
|
||||
bedTempC: number;
|
||||
progressPct: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
printer: Printer;
|
||||
live?: LiveTelemetry;
|
||||
}>();
|
||||
|
||||
const state = computed(() => props.live?.state ?? "offline");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="printer-card" :class="`is-${state}`">
|
||||
<header>
|
||||
<h3>{{ printer.name }}</h3>
|
||||
<span class="printer-card__model">{{ printer.model }}</span>
|
||||
</header>
|
||||
|
||||
<p class="printer-card__state">{{ state }}</p>
|
||||
|
||||
<TemperatureGraph
|
||||
v-if="live"
|
||||
:nozzle-temp-c="live.nozzleTempC"
|
||||
:bed-temp-c="live.bedTempC"
|
||||
/>
|
||||
|
||||
<progress v-if="live" :value="live.progressPct" max="100" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.printer-card {
|
||||
border: 1px solid #1f232c;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: #12151c;
|
||||
}
|
||||
|
||||
.printer-card__model {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.printer-card.is-printing .printer-card__state {
|
||||
color: #35d07f;
|
||||
}
|
||||
|
||||
.printer-card.is-error .printer-card__state {
|
||||
color: #f2604c;
|
||||
}
|
||||
|
||||
.printer-card.is-offline .printer-card__state {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nozzleTempC: number;
|
||||
bedTempC: number;
|
||||
maxSamples?: number;
|
||||
}>();
|
||||
|
||||
const MAX_SAMPLES = props.maxSamples ?? 60;
|
||||
const nozzleHistory = ref<number[]>([]);
|
||||
const bedHistory = ref<number[]>([]);
|
||||
|
||||
watch(
|
||||
() => [props.nozzleTempC, props.bedTempC] as const,
|
||||
([nozzle, bed]) => {
|
||||
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
|
||||
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function toPoints(history: number[], height: number) {
|
||||
if (history.length === 0) return "";
|
||||
const max = Math.max(...history, 1);
|
||||
const step = 100 / Math.max(history.length - 1, 1);
|
||||
return history
|
||||
.map((value, index) => `${index * step},${height - (value / max) * height}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const nozzlePoints = computed(() => toPoints(nozzleHistory.value, 40));
|
||||
const bedPoints = computed(() => toPoints(bedHistory.value, 40));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
nozzleHistory.value = [];
|
||||
bedHistory.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="temp-graph">
|
||||
<svg viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||
<polyline class="temp-graph__nozzle" :points="nozzlePoints" fill="none" />
|
||||
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
|
||||
</svg>
|
||||
<div class="temp-graph__labels">
|
||||
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
|
||||
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.temp-graph svg {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.temp-graph__nozzle {
|
||||
stroke: #f2a33c;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.temp-graph__bed {
|
||||
stroke: #4c9df2;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.temp-graph__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
streamBaseUrl: string;
|
||||
cameraId: string;
|
||||
}>();
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||
const pc = ref<RTCPeerConnection | null>(null);
|
||||
const status = ref<"connecting" | "connected" | "failed">("connecting");
|
||||
|
||||
async function connect() {
|
||||
status.value = "connecting";
|
||||
pc.value?.close();
|
||||
|
||||
const connection = new RTCPeerConnection();
|
||||
pc.value = connection;
|
||||
|
||||
connection.addTransceiver("video", { direction: "recvonly" });
|
||||
connection.ontrack = (event) => {
|
||||
if (videoEl.value) videoEl.value.srcObject = event.streams[0];
|
||||
};
|
||||
connection.onconnectionstatechange = () => {
|
||||
if (connection.connectionState === "connected") status.value = "connected";
|
||||
if (connection.connectionState === "failed") status.value = "failed";
|
||||
};
|
||||
|
||||
const offer = await connection.createOffer();
|
||||
await connection.setLocalDescription(offer);
|
||||
|
||||
// go2rtc's WebRTC WHEP-style signaling endpoint.
|
||||
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/sdp" },
|
||||
body: offer.sdp,
|
||||
});
|
||||
|
||||
const answerSdp = await response.text();
|
||||
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
|
||||
}
|
||||
|
||||
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pc.value?.close();
|
||||
pc.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="webrtc-view">
|
||||
<video ref="videoEl" autoplay playsinline muted />
|
||||
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.webrtc-view {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.webrtc-view video {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.webrtc-view__status {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 8px;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.webrtc-view__status.is-connected {
|
||||
color: #35d07f;
|
||||
}
|
||||
|
||||
.webrtc-view__status.is-failed {
|
||||
color: #f2604c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as PrinterCard } from "./components/PrinterCard.vue";
|
||||
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||
Reference in New Issue
Block a user