Spaces:
Running
Running
| <script setup lang="ts"> | |
| import { computed, ref, type CSSProperties } from "vue"; | |
| import { useCompareStage } from "../composables/useCompareStage"; | |
| import type { ResultInfo } from "../lib/types"; | |
| const props = defineProps<{ | |
| baseImageUrl: string; | |
| overlayImageUrl: string; | |
| baseLabel: string; | |
| baseResolution: string; | |
| overlayResolution: string; | |
| emptyMessage: string; | |
| resultInfo: ResultInfo; | |
| canDownload: boolean; | |
| statusTone: string; | |
| }>(); | |
| const emit = defineEmits<{ | |
| download: []; | |
| }>(); | |
| const compareStageRef = ref<HTMLElement | null>(null); | |
| const hasBaseImage = computed(() => Boolean(props.baseImageUrl)); | |
| const hasOverlayImage = computed(() => Boolean(props.overlayImageUrl)); | |
| const { handleWheel, preventContextMenu, startCompareDrag, styleVars, zoomLabel, zoomScale } = | |
| useCompareStage(compareStageRef, hasBaseImage, hasOverlayImage); | |
| const progressFillWidth = computed(() => { | |
| const progressMatch = props.resultInfo.progress.match(/(\d+(?:\.\d+)?)%/); | |
| const rawProgress = progressMatch ? Number.parseFloat(progressMatch[1]) : 0; | |
| const clampedProgress = Number.isFinite(rawProgress) ? Math.min(100, Math.max(0, rawProgress)) : 0; | |
| const tileMatch = props.resultInfo.tiles.match(/^(\d+)\s*\/\s*(\d+)$/); | |
| const totalTiles = tileMatch ? Number.parseInt(tileMatch[2], 10) : 0; | |
| if (totalTiles === 1 && clampedProgress < 100) { | |
| return `${Math.max(clampedProgress, 33.3333)}%`; | |
| } | |
| return `${clampedProgress}%`; | |
| }); | |
| const progressFillClass = computed(() => { | |
| if (props.statusTone === "success") { | |
| return "bg-emerald-400/80"; | |
| } | |
| if (props.statusTone === "error") { | |
| return "bg-rose-400/80"; | |
| } | |
| if (props.statusTone === "busy") { | |
| return "bg-amber-400/80"; | |
| } | |
| return "bg-stone-300/90"; | |
| }); | |
| const checkerboardBackground = [ | |
| "linear-gradient(45deg, #f0f0f0 25%, transparent 25%) -16px 0 / 32px 32px", | |
| "linear-gradient(-45deg, #f0f0f0 25%, transparent 25%) -16px 0 / 32px 32px", | |
| "linear-gradient(45deg, transparent 75%, #f0f0f0 75%) -16px 0 / 32px 32px", | |
| "linear-gradient(-45deg, transparent 75%, #f0f0f0 75%) -16px 0 / 32px 32px", | |
| "#fff", | |
| ].join(", "); | |
| const compareStageStyle = computed(() => ({ | |
| ...styleVars.value, | |
| background: checkerboardBackground, | |
| })); | |
| const baseImageStyle = computed<CSSProperties>(() => ({ | |
| transform: "scale(var(--zoom))", | |
| transformOrigin: "var(--origin-x) var(--origin-y)", | |
| imageRendering: zoomScale.value > 1 ? "pixelated" : "auto", | |
| })); | |
| const overlayImageStyle = computed<CSSProperties>(() => ({ | |
| ...baseImageStyle.value, | |
| clipPath: "inset(0 calc(100% - var(--split-adjusted)) 0 0)", | |
| })); | |
| const dividerStyle = { | |
| left: "calc(var(--split) - 1px)", | |
| background: "linear-gradient(180deg, transparent, #666 18%, #333 50%, #666 82%, transparent)", | |
| boxShadow: "0 0 0 1px rgba(0,0,0,0.1), 0 0 12px rgba(0,0,0,0.2)", | |
| }; | |
| </script> | |
| <template> | |
| <section class="flex min-h-0 flex-1 flex-col gap-2"> | |
| <div | |
| ref="compareStageRef" | |
| class="relative flex-1 overflow-hidden rounded-xl border border-stone-300 bg-white touch-none" | |
| :style="compareStageStyle" | |
| @pointerdown="startCompareDrag" | |
| @contextmenu="preventContextMenu" | |
| @wheel="handleWheel" | |
| > | |
| <img | |
| :src="baseImageUrl" | |
| class="pointer-events-none absolute inset-0 h-full w-full select-none object-contain" | |
| :style="baseImageStyle" | |
| :alt="`${baseLabel} image`" | |
| :hidden="!hasBaseImage" | |
| /> | |
| <img | |
| :src="overlayImageUrl" | |
| class="pointer-events-none absolute inset-0 h-full w-full select-none object-contain" | |
| :style="overlayImageStyle" | |
| alt="Upscaled output" | |
| :hidden="!hasOverlayImage" | |
| /> | |
| <div | |
| class="pointer-events-none absolute inset-y-0 w-0.5" | |
| :style="dividerStyle" | |
| :hidden="!(hasBaseImage && hasOverlayImage)" | |
| ></div> | |
| <div | |
| class="pointer-events-none absolute left-3 top-3 flex flex-col gap-0.5 rounded-lg border border-stone-300 bg-white/95 px-2.5 py-1.5 text-xs text-stone-600 shadow-sm" | |
| :hidden="!hasOverlayImage" | |
| > | |
| <div class="font-semibold text-stone-800">Upscaled</div> | |
| <div class="text-[11px] text-stone-500">{{ overlayResolution }}</div> | |
| </div> | |
| <div | |
| class="pointer-events-none absolute right-3 top-3 flex flex-col gap-0.5 rounded-lg border border-stone-300 bg-white/95 px-2.5 py-1.5 text-xs text-stone-600 shadow-sm" | |
| :hidden="!hasBaseImage" | |
| > | |
| <div class="font-semibold text-stone-800">{{ baseLabel }}</div> | |
| <div class="text-[11px] text-stone-500">{{ baseResolution }}</div> | |
| </div> | |
| <div | |
| class="absolute bottom-3 right-3 min-w-14 rounded-lg border border-stone-300 bg-white/95 px-2.5 py-1.5 text-center text-xs font-medium text-stone-600 shadow-sm" | |
| > | |
| {{ zoomLabel }} | |
| </div> | |
| <div | |
| class="absolute inset-0 grid place-items-center bg-white/50 p-8 text-center text-sm text-stone-500" | |
| :hidden="hasBaseImage" | |
| > | |
| {{ emptyMessage }} | |
| </div> | |
| </div> | |
| <div | |
| class="relative overflow-hidden rounded-xl border border-stone-300 bg-white/85 px-4 py-2.5 text-xs text-stone-700 shadow-sm" | |
| > | |
| <div | |
| :class="[ | |
| 'pointer-events-none absolute left-0 top-0 h-2 transition-[width] duration-300', | |
| progressFillClass, | |
| ]" | |
| :style="{ width: progressFillWidth }" | |
| ></div> | |
| <div class="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> | |
| <div class="flex flex-wrap gap-x-4 gap-y-2"> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Progress:</span> | |
| <span class="text-stone-800">{{ resultInfo.progress }}</span> | |
| </div> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Tiles:</span> | |
| <span class="text-stone-800">{{ resultInfo.tiles }}</span> | |
| </div> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Avg/tile:</span> | |
| <span class="text-stone-800">{{ resultInfo.speed }}</span> | |
| </div> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Run time:</span> | |
| <span class="text-stone-800">{{ resultInfo.totalTime }}</span> | |
| </div> | |
| </div> | |
| <div class="flex flex-wrap items-center gap-x-4 gap-y-2"> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Resolution:</span> | |
| <span class="text-stone-800">{{ resultInfo.resolution }}</span> | |
| </div> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-medium text-stone-500">Size:</span> | |
| <span class="text-stone-800">{{ resultInfo.size }}</span> | |
| </div> | |
| <button | |
| type="button" | |
| class="inline-flex items-center justify-center rounded-md bg-white px-2.5 py-1.5 text-xs font-semibold text-stone-900 ring-1 ring-inset ring-stone-300 transition hover:bg-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-400 disabled:cursor-not-allowed disabled:bg-stone-100 disabled:text-stone-400 disabled:ring-stone-200" | |
| :disabled="!canDownload" | |
| @click="emit('download')" | |
| > | |
| Download | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </template> | |