Unify parsing workers, analytics API with PostgreSQL, map UI, and PI distribution into centers/ with Docker Compose. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
3.7 KiB
Vue
200 lines
3.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from "vue";
|
|
import type { MapObject } from "../types/object";
|
|
|
|
const props = defineProps<{
|
|
modelValue: number;
|
|
min: number;
|
|
max: number;
|
|
objects: MapObject[];
|
|
visibleCount: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: number];
|
|
}>();
|
|
|
|
const range = computed({
|
|
get: () => props.modelValue,
|
|
set: (value) => emit("update:modelValue", value),
|
|
});
|
|
|
|
const formattedTime = computed(() =>
|
|
new Date(props.modelValue).toLocaleString("ru-RU", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
);
|
|
|
|
const formattedMin = computed(() =>
|
|
new Date(props.min).toLocaleDateString("ru-RU", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
}),
|
|
);
|
|
|
|
const formattedMax = computed(() =>
|
|
new Date(props.max).toLocaleDateString("ru-RU", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
}),
|
|
);
|
|
|
|
const markers = computed(() => {
|
|
if (props.max <= props.min) return [];
|
|
|
|
const span = props.max - props.min;
|
|
return props.objects.map((obj) => {
|
|
const time = new Date(obj.created_at).getTime();
|
|
const percent = ((time - props.min) / span) * 100;
|
|
return {
|
|
id: obj.id,
|
|
name: obj.name,
|
|
percent: Math.min(100, Math.max(0, percent)),
|
|
visible: time <= props.modelValue,
|
|
};
|
|
});
|
|
});
|
|
|
|
const hasRange = computed(() => props.max > props.min);
|
|
</script>
|
|
|
|
<template>
|
|
<footer class="timeline">
|
|
<div class="timeline-header">
|
|
<span class="label">Таймлайн появления</span>
|
|
<span class="current-time">{{ formattedTime }}</span>
|
|
<span class="count">{{ visibleCount }} / {{ objects.length }} объектов</span>
|
|
</div>
|
|
|
|
<div class="slider-wrap">
|
|
<span class="edge-label">{{ formattedMin }}</span>
|
|
|
|
<div class="slider-track">
|
|
<div
|
|
v-for="marker in markers"
|
|
:key="marker.id"
|
|
class="object-marker"
|
|
:class="{ visible: marker.visible }"
|
|
:style="{ left: `${marker.percent}%` }"
|
|
:title="marker.name"
|
|
/>
|
|
|
|
<input
|
|
v-model.number="range"
|
|
class="slider"
|
|
type="range"
|
|
:min="min"
|
|
:max="max"
|
|
:step="hasRange ? Math.max(1, Math.floor((max - min) / 500)) : 1"
|
|
:disabled="!hasRange"
|
|
/>
|
|
</div>
|
|
|
|
<span class="edge-label">{{ formattedMax }}</span>
|
|
</div>
|
|
</footer>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.timeline {
|
|
position: relative;
|
|
z-index: 3;
|
|
flex-shrink: 0;
|
|
padding: 0.75rem 1.25rem 1rem;
|
|
background: #fff;
|
|
border-top: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.timeline-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 0.625rem;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.label {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.current-time {
|
|
color: #2563eb;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.count {
|
|
margin-left: auto;
|
|
color: #666;
|
|
}
|
|
|
|
.slider-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.edge-label {
|
|
flex-shrink: 0;
|
|
width: 5.5rem;
|
|
font-size: 0.75rem;
|
|
color: #888;
|
|
text-align: center;
|
|
}
|
|
|
|
.edge-label:first-child {
|
|
text-align: left;
|
|
}
|
|
|
|
.edge-label:last-child {
|
|
text-align: right;
|
|
}
|
|
|
|
.slider-track {
|
|
position: relative;
|
|
flex: 1;
|
|
height: 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.object-marker {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 8px;
|
|
height: 8px;
|
|
margin-left: -4px;
|
|
margin-top: -4px;
|
|
border-radius: 50%;
|
|
background: #bbb;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
transition: background 0.15s, transform 0.15s;
|
|
}
|
|
|
|
.object-marker.visible {
|
|
background: #2563eb;
|
|
transform: scale(1.25);
|
|
}
|
|
|
|
.slider {
|
|
position: relative;
|
|
z-index: 2;
|
|
width: 100%;
|
|
margin: 0;
|
|
cursor: pointer;
|
|
accent-color: #2563eb;
|
|
}
|
|
|
|
.slider:disabled {
|
|
cursor: default;
|
|
opacity: 0.5;
|
|
}
|
|
</style>
|