Добавить Vue 3 web-dashboard с расширенным API и Docker-сборкой.
Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import PipelineCanvas from "@/components/pipeline/PipelineCanvas.vue";
|
||||
import StageSettingsDialog from "@/components/pipeline/StageSettingsDialog.vue";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const selectedPreset = ref("");
|
||||
const snapshotName = ref("");
|
||||
const presetTitle = ref("");
|
||||
const settings = ref(null);
|
||||
|
||||
async function onApplyPreset() {
|
||||
if (!selectedPreset.value) return;
|
||||
await store.applyPreset(selectedPreset.value);
|
||||
}
|
||||
|
||||
async function onRun() {
|
||||
const useBinary = (store.currentFile?.size || 0) > 8 * 1024 * 1024;
|
||||
await store.runPipeline({ geometryFormat: useBinary ? "binary" : "json" });
|
||||
if (store.geometryUrl) {
|
||||
const workId = store.geometryUrl.split("/").pop();
|
||||
await store.loadGeometryFromUrl(workId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerateDemo() {
|
||||
await store.runPipeline({ useDemo: true, geometryFormat: "json" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel controls">
|
||||
<div class="toolbar">
|
||||
<select v-model="store.demoSurfaceType">
|
||||
<option v-for="type in store.demoSurfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
<button type="button" @click="onGenerateDemo" :disabled="store.busy">Сгенерировать</button>
|
||||
<label class="file-label">
|
||||
Загрузить
|
||||
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PipelineCanvas @open-settings="settings?.open($event)" />
|
||||
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" v-model="store.surfaceVisible" /> Показать mesh</label>
|
||||
<button type="button" class="primary" :disabled="store.busy" @click="onRun">Применить</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<select v-model="selectedPreset">
|
||||
<option value="">Пресет...</option>
|
||||
<option v-for="preset in store.presetItems" :key="preset.idValue || preset.id" :value="preset.idValue || preset.id">
|
||||
{{ preset.title }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" @click="onApplyPreset">Применить пресет</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<input v-model="presetTitle" placeholder="Имя пресета" />
|
||||
<button type="button" @click="store.saveCurrentPreset(presetTitle)" :disabled="!presetTitle">Сохранить пресет</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<input v-model="snapshotName" placeholder="Snapshot name" />
|
||||
<button type="button" @click="store.saveSnapshot(snapshotName)">Save snapshot</button>
|
||||
<button
|
||||
v-for="snap in store.snapshots"
|
||||
:key="snap.name"
|
||||
type="button"
|
||||
@click="store.loadSnapshot(snap.name)"
|
||||
>{{ snap.name }}</button>
|
||||
</div>
|
||||
|
||||
<p class="status">{{ store.statusText }}</p>
|
||||
<StageSettingsDialog ref="settings" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.file-label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; }
|
||||
.status { color: var(--muted-text); font-size: 13px; white-space: pre-wrap; margin: 0; }
|
||||
.presets input { flex: 1; min-width: 120px; }
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wizard panel">
|
||||
<h3>Quick Wizard</h3>
|
||||
<label>Data profile</label>
|
||||
<select v-model="store.wizardProfile">
|
||||
<option value="general">general</option>
|
||||
<option value="urban_scan">urban_scan</option>
|
||||
<option value="indoor_object">indoor_object</option>
|
||||
</select>
|
||||
<label>Optimization goal</label>
|
||||
<select v-model="store.wizardGoal">
|
||||
<option value="speed">speed</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="quality">quality</option>
|
||||
</select>
|
||||
<p class="hint">Wizard builds a start chain and explains trade-offs.</p>
|
||||
<button type="button" @click="store.applyWizard()">Generate suggested chain</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard { display: grid; gap: 8px; }
|
||||
.wizard h3 { margin: 0; font-size: 14px; }
|
||||
.hint { color: var(--hint-text); font-size: 12px; margin: 0; }
|
||||
label { font-size: 12px; color: var(--label-text); }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
|
||||
function localizedHealth(value) {
|
||||
if (value === "OK") return "ОК";
|
||||
if (value === "Warning") return "Предупреждение";
|
||||
if (value === "Risk") return "Риск";
|
||||
return value || "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel metrics-strip">
|
||||
<h3>Сводка</h3>
|
||||
<div class="row">
|
||||
<strong>Состояние: {{ localizedHealth(store.chainHealth) }}</strong>
|
||||
<span>Треугольники: {{ store.metrics.triangles }}</span>
|
||||
<span>Реконструкция, мс: {{ store.metrics.reconstructMs }}</span>
|
||||
</div>
|
||||
<p class="recommendation">{{ store.recommendation }}</p>
|
||||
<ul v-if="store.warningsList.length" class="warnings">
|
||||
<li v-for="(warning, index) in store.warningsList" :key="index">{{ warning }}</li>
|
||||
</ul>
|
||||
<table v-if="store.stageMetrics.length" class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Этап</th>
|
||||
<th>Вход</th>
|
||||
<th>Выход</th>
|
||||
<th>Удалено</th>
|
||||
<th>мс</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in store.stageMetrics" :key="row.stageId">
|
||||
<td>{{ row.stageId }}</td>
|
||||
<td>{{ row.inputPoints }}</td>
|
||||
<td>{{ row.outputPoints }}</td>
|
||||
<td>{{ row.removedPoints }}</td>
|
||||
<td>{{ row.elapsedMs }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="summary-grid">
|
||||
<div>Вход: {{ store.metrics.inputPoints }}</div>
|
||||
<div>После preprocess: {{ store.metrics.afterPreprocess }}</div>
|
||||
<div>Кластеры: {{ store.metrics.clusters }}</div>
|
||||
<div>Удалено: {{ store.metrics.removedPoints }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metrics-strip h3 { margin: 0 0 8px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; }
|
||||
.recommendation { color: var(--summary-recommendation); font-size: 13px; }
|
||||
.warnings { margin: 8px 0; padding-left: 18px; color: var(--warning-text); font-size: 13px; }
|
||||
.metrics-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||
.metrics-table th, .metrics-table td { border-bottom: 1px solid var(--card-border); padding: 4px; text-align: left; }
|
||||
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; font-size: 13px; color: var(--summary-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import StageChainView from "./StageChainView.vue";
|
||||
import StagePalette from "./StagePalette.vue";
|
||||
|
||||
const emit = defineEmits(["openSettings"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas panel">
|
||||
<div class="canvas-grid">
|
||||
<StageChainView @open-settings="emit('openSettings', $event)" />
|
||||
<StagePalette />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
max-height: min(420px, 48vh);
|
||||
}
|
||||
|
||||
.canvas-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.canvas-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import Sortable from "sortablejs";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const listRef = ref(null);
|
||||
const emit = defineEmits(["openSettings"]);
|
||||
|
||||
function categoryColor(category) {
|
||||
const map = {
|
||||
"Обрезка": "#88d1ff",
|
||||
"NaN (предочистка)": "#8fe8ff",
|
||||
"Условия/Индексы": "#9fd7ff",
|
||||
"Шум": "#a4f4b9",
|
||||
"Морфология": "#9cf5d1",
|
||||
"Прореживание": "#ffcb8a",
|
||||
"Сглаживание": "#ffd892",
|
||||
"Нормали": "#d2b7ff",
|
||||
"Реконструкция": "#ff9dc4",
|
||||
};
|
||||
return map[category] || "#9fb4da";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!listRef.value) return;
|
||||
Sortable.create(listRef.value, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd(evt) {
|
||||
if (evt.oldIndex == null || evt.newIndex == null) return;
|
||||
store.moveStage(evt.oldIndex, evt.newIndex);
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="chain-list">
|
||||
<div
|
||||
v-for="(card, index) in store.stageCards"
|
||||
:key="`${card.id}-${index}`"
|
||||
class="chain-row"
|
||||
:class="{ selected: store.selectedStageIndex === index, disabled: !card.enabled }"
|
||||
@click="store.setSelectedStageIndex(index)"
|
||||
@dblclick="emit('openSettings', card)"
|
||||
>
|
||||
<span class="accent" :style="{ background: categoryColor(card.category) }" />
|
||||
<span class="drag-handle" :class="{ hidden: card.family === 'reconstruction' }">↕</span>
|
||||
<div class="meta">
|
||||
<strong>{{ card.title }}</strong>
|
||||
<small>{{ card.category }} | {{ card.id }}</small>
|
||||
</div>
|
||||
<button
|
||||
v-if="card.family !== 'reconstruction'"
|
||||
type="button"
|
||||
class="mini"
|
||||
@click.stop="store.removeStage(index)"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: min(420px, 48vh);
|
||||
overflow: auto;
|
||||
}
|
||||
.chain-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4px 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--chain-idle-border);
|
||||
background: var(--chain-enabled-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chain-row.selected { border-color: var(--chain-selected-border); }
|
||||
.chain-row.disabled { background: var(--chain-disabled-bg); opacity: 0.7; }
|
||||
.accent { height: 100%; border-radius: 2px; }
|
||||
.drag-handle { text-align: center; color: var(--chain-handle-text); cursor: grab; }
|
||||
.drag-handle.hidden { visibility: hidden; }
|
||||
.meta { display: grid; gap: 2px; min-width: 0; }
|
||||
.meta strong, .meta small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta small { color: var(--chain-handle-text); font-size: 11px; }
|
||||
.mini { width: 28px; height: 28px; padding: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const { theme } = useTheme();
|
||||
|
||||
function addFilter(item) {
|
||||
store.addStage(item.idValue);
|
||||
}
|
||||
|
||||
function phaseHeaderStyle(group) {
|
||||
if (theme.value === "light") {
|
||||
return {
|
||||
color: "#1e293b",
|
||||
borderColor: group.phaseFilterBorder,
|
||||
background: `color-mix(in srgb, ${group.phaseAccent} 16%, #ffffff)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: group.phaseAccent,
|
||||
borderColor: group.phaseFilterBorder,
|
||||
background: group.phaseFilterBg,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="palette">
|
||||
<section v-for="group in store.paletteGroups" :key="group.phaseId" class="phase">
|
||||
<header :style="phaseHeaderStyle(group)">
|
||||
{{ group.phaseTitle }}
|
||||
</header>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.idValue"
|
||||
type="button"
|
||||
class="palette-item"
|
||||
@click="addFilter(item)"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<small>{{ item.hint }}</small>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.palette {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: min(420px, 48vh);
|
||||
overflow: auto;
|
||||
}
|
||||
.phase header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--palette-filter-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.palette-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--palette-filter-bg);
|
||||
border-color: var(--palette-filter-border);
|
||||
color: var(--control-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.palette-item span,
|
||||
.palette-item small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-item small { color: var(--palette-hint-text); font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { parseDefaults, serializeParams, paramDescription } from "@/catalog/pipelineUiCatalog";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const visible = ref(false);
|
||||
const paramItems = ref([]);
|
||||
const editingCard = ref(null);
|
||||
|
||||
function open(card) {
|
||||
editingCard.value = card;
|
||||
paramItems.value = parseDefaults(card.defaults || "");
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const index = store.stageCards.findIndex((c) => c.id === editingCard.value?.id);
|
||||
if (index >= 0) {
|
||||
store.setStageDefaults(index, serializeParams(paramItems.value));
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
if (!editingCard.value) return;
|
||||
const defaults = await store.defaultsForStage(editingCard.value.id);
|
||||
paramItems.value = parseDefaults(defaults);
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-backdrop" @click.self="close">
|
||||
<div class="dialog panel">
|
||||
<h3>{{ editingCard?.title }}</h3>
|
||||
<p class="hint">{{ editingCard?.hint }}</p>
|
||||
<div v-for="(param, index) in paramItems" :key="param.key" class="param-row">
|
||||
<label>{{ param.key }}</label>
|
||||
<select v-if="param.kind === 'bool'" v-model="param.value">
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
<input v-else v-model="param.value" />
|
||||
<small>{{ paramDescription(param.key) }}</small>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" @click="resetDefaults">Установить по умолчанию</button>
|
||||
<button type="button" class="primary" @click="save">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-backdrop { position: fixed; inset: 0; background: var(--dialog-backdrop); display: grid; place-items: center; z-index: 20; }
|
||||
.dialog { width: min(420px, 92vw); max-height: 80vh; overflow: auto; }
|
||||
.hint { color: var(--dialog-hint-text); font-size: 13px; }
|
||||
.param-row { display: grid; gap: 4px; margin-bottom: 10px; }
|
||||
.param-row label { font-size: 12px; color: var(--dialog-label-text); }
|
||||
.param-row small { color: var(--dialog-hint-text); font-size: 11px; }
|
||||
.actions { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(["download", "resetCamera"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modebar" role="toolbar" aria-label="Управление 3D-просмотром">
|
||||
<button
|
||||
type="button"
|
||||
class="modebar-btn"
|
||||
title="Сохранить снимок"
|
||||
aria-label="Сохранить снимок"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
|
||||
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modebar-btn"
|
||||
title="Сбросить камеру"
|
||||
aria-label="Сбросить камеру"
|
||||
@click="emit('resetCamera')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 10.5 10.5 4 17 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M10.5 4v16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modebar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.modebar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--modebar-icon, #636363);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modebar-btn:hover {
|
||||
background: color-mix(in srgb, var(--control-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.modebar-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||
import ViewerModebar from "./ViewerModebar.vue";
|
||||
|
||||
const VIEWPORT_HEIGHT_KEY = "dotstosirface-viewport-height";
|
||||
const MIN_VIEWPORT_HEIGHT = 240;
|
||||
const MAX_VIEWPORT_HEIGHT = Math.min(window.innerHeight - 80, 1200);
|
||||
|
||||
const store = usePipelineStore();
|
||||
const { theme } = useTheme();
|
||||
const viewportRef = ref(null);
|
||||
const isFullscreen = ref(false);
|
||||
const viewportHeight = ref(loadViewportHeight());
|
||||
const {
|
||||
renderGeometry,
|
||||
resize,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
downloadSnapshot,
|
||||
} = usePointCloudViewer(viewportRef);
|
||||
|
||||
function loadViewportHeight() {
|
||||
const saved = Number(localStorage.getItem(VIEWPORT_HEIGHT_KEY));
|
||||
if (Number.isFinite(saved) && saved >= MIN_VIEWPORT_HEIGHT) {
|
||||
return Math.min(saved, MAX_VIEWPORT_HEIGHT);
|
||||
}
|
||||
return 520;
|
||||
}
|
||||
|
||||
function saveViewportHeight() {
|
||||
localStorage.setItem(VIEWPORT_HEIGHT_KEY, String(viewportHeight.value));
|
||||
}
|
||||
|
||||
function viewerBackground() {
|
||||
return theme.value === "light" ? 0xe2e8f0 : 0x0b1118;
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = document.fullscreenElement === viewportRef.value;
|
||||
resize();
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!viewportRef.value) return;
|
||||
if (document.fullscreenElement === viewportRef.value) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await viewportRef.value.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (isFullscreen.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
const startY = event.clientY;
|
||||
const startHeight = viewportHeight.value;
|
||||
|
||||
function onMove(moveEvent) {
|
||||
const nextHeight = startHeight + (moveEvent.clientY - startY);
|
||||
viewportHeight.value = Math.min(
|
||||
Math.max(nextHeight, MIN_VIEWPORT_HEIGHT),
|
||||
Math.min(window.innerHeight - 80, 1200),
|
||||
);
|
||||
resize();
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
saveViewportHeight();
|
||||
}
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [store.viewerPoints, store.viewerTriangles, store.surfaceVisible],
|
||||
() => {
|
||||
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
|
||||
resize();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(theme, () => {
|
||||
setBackground(viewerBackground());
|
||||
}, { immediate: true });
|
||||
|
||||
watch(viewportHeight, () => {
|
||||
resize();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
resize();
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel viewer">
|
||||
<h2>3D Viewer</h2>
|
||||
<div
|
||||
class="viewport-shell"
|
||||
:class="{ fullscreen: isFullscreen }"
|
||||
:style="isFullscreen ? undefined : { height: `${viewportHeight}px` }"
|
||||
>
|
||||
<div ref="viewportRef" class="viewport">
|
||||
<ViewerModebar
|
||||
@download="downloadSnapshot"
|
||||
@reset-camera="resetCamera"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="fullscreen-btn"
|
||||
:title="isFullscreen ? 'Свернуть' : 'Развернуть на весь экран'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!isFullscreen" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3H3v5M16 3h5v5M16 21h5v-5M8 21H3v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 9H3V3M15 9h6V3M15 15h6v6M9 15H3v6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isFullscreen"
|
||||
class="resize-handle"
|
||||
title="Потяните, чтобы изменить высоту"
|
||||
@mousedown="startResize"
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">Drag to rotate, wheel to zoom.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer { display: flex; flex-direction: column; min-width: 0; }
|
||||
.viewer h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
|
||||
.viewport-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--viewer-bg);
|
||||
}
|
||||
|
||||
.viewport:fullscreen {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--modebar-icon, #636363);
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.fullscreen-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
flex: 0 0 10px;
|
||||
margin-top: 4px;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: "";
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--card-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover::after {
|
||||
background: var(--chain-selected-border);
|
||||
}
|
||||
|
||||
.hint { color: var(--hint-text); font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user