| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072 |
- <template>
- <div class="growth-stage-timeline">
- <div class="growth-stage-timeline__scroll" ref="scrollRef">
- <div class="growth-stage-timeline__inner" :style="innerStyle">
- <!-- 轨道:横线 + 节点 + 拖动手柄 -->
- <div class="growth-stage-timeline__track" ref="trackRef">
- <div class="growth-stage-timeline__track-line" aria-hidden="true"></div>
- <div
- class="growth-stage-timeline__track-grid"
- :style="{ gridTemplateColumns: gridCols }"
- >
- <div
- v-for="(_, i) in normalizedStages"
- :key="'dot-' + i"
- class="growth-stage-timeline__dot-wrap"
- >
- <span class="growth-stage-timeline__dot"></span>
- </div>
- </div>
- <div
- ref="handleRef"
- class="growth-stage-timeline__handle"
- :style="handleStyle"
- @pointerdown.stop.prevent="onHandlePointerDown"
- >
- <div class="growth-stage-timeline__handle-core">
- <div
- v-if="showHandleTooltip"
- class="growth-stage-timeline__tooltip-wrap"
- >
- <div
- ref="tooltipBubbleRef"
- class="growth-stage-timeline__tooltip"
- :style="tooltipBubbleStyle"
- >
- {{ displayTooltipText }}
- </div>
- <div
- class="growth-stage-timeline__tooltip-caret"
- aria-hidden="true"
- ></div>
- </div>
- <div
- class="growth-stage-timeline__handle-body"
- :class="{
- 'growth-stage-timeline__handle-body--wide':
- activeLabelIsShort,
- }"
- >
- <span class="growth-stage-timeline__handle-bar"></span>
- <span class="growth-stage-timeline__handle-bar"></span>
- <span class="growth-stage-timeline__handle-bar"></span>
- </div>
- </div>
- </div>
- </div>
- <!-- 阶段文案 + 绿色标签 -->
- <div
- class="growth-stage-timeline__labels"
- :style="{ gridTemplateColumns: gridCols }"
- >
- <div
- v-for="(stage, i) in normalizedStages"
- :key="'lb-' + i"
- class="growth-stage-timeline__label-col"
- :class="{
- 'growth-stage-timeline__label-col--wide':
- isShortStageLabel(stage.label),
- }"
- >
- <div
- class="growth-stage-timeline__label-text"
- :class="{
- 'growth-stage-timeline__label-text--active':
- i === activeIndex,
- }"
- >
- {{ stage.label }}
- </div>
- <div
- v-if="stage.tags && stage.tags.length"
- class="growth-stage-timeline__tags"
- >
- <span
- v-for="(tag, ti) in stage.tags"
- :key="ti"
- class="growth-stage-timeline__tag"
- >{{ tag }}</span>
- </div>
- </div>
- </div>
- <!-- 生育期卡片:与上方阶段列同网格对齐,随横向滚动联动 -->
- <div
- class="growth-stage-timeline__bg"
- :style="{ gridTemplateColumns: gridCols }"
- >
- <div
- v-for="(run, ri) in periodRuns"
- :key="'bg-run-' + ri"
- class="growth-stage-timeline__bg-cell growth-stage-timeline__bg-cell--period"
- :class="{
- 'growth-stage-timeline__bg-cell--active':
- ri === activePeriodRunIndex,
- }"
- :style="{ gridColumn: `span ${run.span}` }"
- >
- <div class="growth-stage-timeline__period-title">
- {{ run.periodTitle }}
- </div>
- <div class="growth-stage-timeline__period-sub">
- {{ run.periodSubtitle }}
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import {
- computed,
- ref,
- watch,
- onMounted,
- onBeforeUnmount,
- nextTick,
- } from "vue";
- import { useI18n } from "@/i18n";
- /**
- * @typedef {Object} GrowthStageItem
- * @property {string} label 节点文案,如「60%展开」
- * @property {string[]} [tags] 绿色标签,如 ['最佳给肥点']
- * @property {string} periodTitle 生育期大标题
- * @property {string} periodSubtitle 生育期描述小字
- * @property {string} [phenophase_code] 物候期编码,由接口/父组件写入
- */
- const props = defineProps({
- /** @type {import('vue').PropType<GrowthStageItem[]>} */
- stages: {
- type: Array,
- default: () => [],
- },
- /** 当前选中的阶段索引(与 v-model 同步);不传时为中间一档 */
- modelValue: {
- type: Number,
- default: undefined,
- },
- tooltipText: {
- type: String,
- default: "此为预估进程,请左右移动进行校准!",
- },
- /** 每列最小宽度(px),列数多时可横向滚动 */
- minColWidth: {
- type: Number,
- default: 72,
- },
- });
- const emit = defineEmits(["update:modelValue", "change", "scrollSettled", "locale-change"]);
- const { t, locale } = useI18n();
- const DEFAULT_TOOLTIP_ZH = "此为预估进程,请左右移动进行校准!";
- const displayTooltipText = computed(() => {
- if (!props.tooltipText || props.tooltipText === DEFAULT_TOOLTIP_ZH) {
- return t("growthStageTimeline.tooltipHint");
- }
- return t(props.tooltipText);
- });
- watch(locale, () => {
- emit("locale-change");
- });
- const scrollRef = ref(null);
- const trackRef = ref(null);
- const handleRef = ref(null);
- const tooltipBubbleRef = ref(null);
- /** 气泡相对手柄水平中心的额外偏移(px),用于避免贴边时被 overflow 裁切 */
- const tooltipShiftPx = ref(0);
- const TOOLTIP_EDGE_MARGIN_PX = 8;
- /** 本次进入页面内展示;拖动手柄产生位移后关闭,下次路由/页面再进入会重新挂载并再次显示 */
- const showHandleTooltip = ref(true);
- const normalizedStages = computed(() =>
- Array.isArray(props.stages) ? props.stages : []
- );
- const colCount = computed(() =>
- Math.max(1, normalizedStages.value.length)
- );
- /** time_discribe 文案过短(<3 字)时,该列占 3 个普通点宽度,便于下方标签展示 */
- const SHORT_LABEL_MAX_LEN = 3;
- const SHORT_LABEL_COL_WEIGHT = 3;
- function isShortStageLabel(label) {
- return String(label ?? "").length < SHORT_LABEL_MAX_LEN;
- }
- function getStageColumnWeight(stage) {
- return isShortStageLabel(stage?.label) ? SHORT_LABEL_COL_WEIGHT : 1;
- }
- const columnWeights = computed(() =>
- normalizedStages.value.map((stage) => getStageColumnWeight(stage))
- );
- const totalColumnWeight = computed(() =>
- Math.max(
- 1,
- columnWeights.value.reduce((sum, weight) => sum + weight, 0)
- )
- );
- const gridCols = computed(() => {
- const stages = normalizedStages.value;
- if (!stages.length) {
- return `minmax(${props.minColWidth}px, 1fr)`;
- }
- return stages
- .map((stage) => {
- const weight = getStageColumnWeight(stage);
- return `minmax(${weight * props.minColWidth}px, ${weight}fr)`;
- })
- .join(" ");
- });
- /** 左右留白:手柄 translate(-50%) 与气泡在首尾否则会溢出滚动宽度,且气泡换行会拉高整列导致视觉上“掉下去” */
- const INNER_EDGE_PAD_PX = 12;
- const innerStyle = computed(() => {
- const minContentWidth = columnWeights.value.reduce(
- (sum, weight) => sum + weight * props.minColWidth,
- 0
- );
- return {
- boxSizing: "border-box",
- minWidth: `${minContentWidth + INNER_EDGE_PAD_PX * 2}px`,
- paddingLeft: `${INNER_EDGE_PAD_PX}px`,
- paddingRight: `${INNER_EDGE_PAD_PX}px`,
- };
- });
- function periodKey(stage) {
- const s = stage || {};
- return `${s.periodTitle || ""}\0${s.periodSubtitle || ""}`;
- }
- /** 连续相同生育期合并为一段,用于背景区 grid-column span */
- const periodRuns = computed(() => {
- const list = normalizedStages.value;
- if (!list.length) {
- return [
- {
- span: 1,
- periodTitle: "",
- periodSubtitle: "",
- },
- ];
- }
- const runs = [];
- let i = 0;
- while (i < list.length) {
- const key = periodKey(list[i]);
- let span = 1;
- let j = i + 1;
- while (j < list.length && periodKey(list[j]) === key) {
- span++;
- j++;
- }
- runs.push({
- span,
- periodTitle: list[i].periodTitle ?? "",
- periodSubtitle: list[i].periodSubtitle ?? "",
- });
- i = j;
- }
- return runs;
- });
- /** 生育期卡片列宽与上方阶段列对齐:每段占 span 列,fr 比例与阶段网格一致 */
- const periodGridCols = computed(() => {
- const runs = periodRuns.value;
- if (!runs.length) {
- return `minmax(${props.minColWidth}px, 1fr)`;
- }
- return runs
- .map(
- (run) =>
- `minmax(${run.span * props.minColWidth}px, ${run.span}fr)`
- )
- .join(" ");
- });
- function clampStageIndex(v) {
- const last = Math.max(0, colCount.value - 1);
- return Math.min(Math.max(0, v), last);
- }
- /** 项数为 n 时默认选中索引 floor((n-1)/2),偶数个时偏左中间 */
- function middleStageIndex() {
- const n = colCount.value;
- return Math.max(0, Math.floor((n - 1) / 2));
- }
- const activeIndex = ref(0);
- const activeLabelIsShort = computed(() =>
- isShortStageLabel(normalizedStages.value[activeIndex.value]?.label)
- );
- watch(
- [colCount, () => props.modelValue],
- () => {
- const mv = props.modelValue;
- const hasParentIndex = mv !== undefined && mv !== null;
- const next = hasParentIndex
- ? clampStageIndex(mv)
- : middleStageIndex();
- activeIndex.value = next;
- // 仅在没有父级索引时回写默认档,避免覆盖父组件按物候编码算好的下标
- if (!hasParentIndex) {
- nextTick(() => {
- emit("update:modelValue", next);
- });
- }
- },
- { immediate: true }
- );
- /** 手柄水平位置:0~1,对应轨道宽度 */
- const handleRatio = ref(0);
- function stageIndexToPeriodRunIndex(stageIdx) {
- let start = 0;
- for (let ri = 0; ri < periodRuns.value.length; ri++) {
- const span = periodRuns.value[ri].span;
- if (stageIdx >= start && stageIdx < start + span) {
- return ri;
- }
- start += span;
- }
- return 0;
- }
- /** 视口中心(手柄)所在生育期段索引,随滚动/拖动实时高亮 */
- const activePeriodRunIndex = computed(() =>
- stageIndexToPeriodRunIndex(ratioToIndex(handleRatio.value))
- );
- function indexToRatio(idx) {
- const weights = columnWeights.value;
- const total = totalColumnWeight.value;
- if (weights.length <= 1) return 0.5;
- let start = 0;
- for (let i = 0; i < idx; i++) {
- start += weights[i] ?? 1;
- }
- const center = start + (weights[idx] ?? 1) / 2;
- return center / total;
- }
- function ratioToIndex(r) {
- const weights = columnWeights.value;
- const total = totalColumnWeight.value;
- const n = weights.length;
- if (n <= 1) return 0;
- const target = Math.min(Math.max(0, r), 1) * total;
- let acc = 0;
- let bestIdx = 0;
- let bestDist = Infinity;
- for (let i = 0; i < n; i++) {
- const center = acc + (weights[i] ?? 1) / 2;
- const dist = Math.abs(target - center);
- if (dist < bestDist) {
- bestDist = dist;
- bestIdx = i;
- }
- acc += weights[i] ?? 1;
- }
- return bestIdx;
- }
- /** 禁止在轨道上横向拖滚:仅在手柄拖动时由脚本更新 scrollLeft */
- let scrollAreaTouchStartX = 0;
- let scrollAreaTouchStartY = 0;
- function onScrollAreaTouchStart(e) {
- if (!e.touches?.length) return;
- scrollAreaTouchStartX = e.touches[0].clientX;
- scrollAreaTouchStartY = e.touches[0].clientY;
- }
- function onScrollAreaTouchMove(e) {
- if (!e.touches?.length) return;
- const dx = e.touches[0].clientX - scrollAreaTouchStartX;
- const dy = e.touches[0].clientY - scrollAreaTouchStartY;
- if (Math.abs(dx) > Math.abs(dy)) {
- e.preventDefault();
- }
- }
- function getScrollLayout() {
- const scrollEl = scrollRef.value;
- if (!scrollEl) return null;
- const inner = scrollEl.querySelector(".growth-stage-timeline__inner");
- if (!inner) return null;
- const w = inner.offsetWidth;
- const cw = scrollEl.clientWidth;
- const maxScroll = Math.max(0, scrollEl.scrollWidth - cw);
- const pad = INNER_EDGE_PAD_PX;
- const contentW = Math.max(0, w - pad * 2);
- return { scrollEl, cw, maxScroll, pad, contentW };
- }
- /** 滚动缓动系数,越小滚动越慢(0~1) */
- const SCROLL_LERP = 0.08;
- let scrollTargetLeft = null;
- let scrollAnimRafId = null;
- function getPeriodRunBounds(runIdx) {
- let stageStart = 0;
- for (let ri = 0; ri < runIdx; ri++) {
- stageStart += periodRuns.value[ri].span;
- }
- const span = periodRuns.value[runIdx]?.span ?? 1;
- const weights = columnWeights.value;
- const total = totalColumnWeight.value;
- if (!weights.length) {
- return { startRatio: 0, endRatio: 1, centerRatio: 0.5 };
- }
- let weightStart = 0;
- for (let i = 0; i < stageStart; i++) {
- weightStart += weights[i] ?? 1;
- }
- let weightSpan = 0;
- for (let i = stageStart; i < stageStart + span; i++) {
- weightSpan += weights[i] ?? 1;
- }
- return {
- startRatio: weightStart / total,
- endRatio: (weightStart + weightSpan) / total,
- centerRatio: (weightStart + weightSpan / 2) / total,
- };
- }
- function ratioToScrollLeft(ratio, stageIdx = null) {
- const m = getScrollLayout();
- if (!m) return null;
- const { cw, maxScroll, pad, contentW } = m;
- const r = Math.min(Math.max(0, ratio), 1);
- const handleCenterX = pad + r * contentW;
- const idx =
- stageIdx != null
- ? clampStageIndex(stageIdx)
- : ratioToIndex(ratio);
- const runIdx = stageIndexToPeriodRunIndex(idx);
- const { startRatio, endRatio } = getPeriodRunBounds(runIdx);
- const periodLeft = pad + startRatio * contentW;
- const periodRight = pad + endRatio * contentW;
- const periodWidth = periodRight - periodLeft;
- let left = handleCenterX - cw / 2;
- if (periodWidth <= cw) {
- if (periodLeft < left) left = periodLeft;
- if (periodRight > left + cw) left = periodRight - cw;
- } else {
- left = Math.max(periodLeft, Math.min(left, periodRight - cw));
- }
- return Math.max(0, Math.min(left, maxScroll));
- }
- function cancelScrollAnimation() {
- if (scrollAnimRafId != null) {
- cancelAnimationFrame(scrollAnimRafId);
- scrollAnimRafId = null;
- }
- }
- function runScrollAnimation() {
- const scrollEl = scrollRef.value;
- if (!scrollEl || scrollTargetLeft == null) {
- scrollAnimRafId = null;
- return;
- }
- const current = scrollEl.scrollLeft;
- const diff = scrollTargetLeft - current;
- if (Math.abs(diff) < 0.5) {
- scrollEl.scrollLeft = scrollTargetLeft;
- scrollTargetLeft = null;
- scrollAnimRafId = null;
- return;
- }
- scrollEl.scrollLeft = current + diff * SCROLL_LERP;
- scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
- }
- function applyScrollToRatio(ratio, immediate = false, stageIdx = null) {
- const left = ratioToScrollLeft(ratio, stageIdx);
- if (left == null) return;
- const scrollEl = scrollRef.value;
- if (!scrollEl) return;
- scrollTargetLeft = left;
- if (immediate) {
- cancelScrollAnimation();
- scrollEl.scrollLeft = left;
- scrollTargetLeft = null;
- return;
- }
- if (scrollAnimRafId == null) {
- scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
- }
- }
- /** 将轨道上的比例位置(0~1,与手柄 left 一致)滚到视口水平中央 */
- function scrollToCenterRatio(ratio, behavior = "smooth", stageIdx = null) {
- applyScrollToRatio(ratio, behavior === "auto", stageIdx);
- }
- function syncScrollToRatio(ratio, immediate = false) {
- applyScrollToRatio(ratio, immediate, ratioToIndex(ratio));
- }
- function onTimelineWheel(e) {
- if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- e.preventDefault();
- }
- }
- watch(
- [activeIndex, colCount],
- () => {
- handleRatio.value = indexToRatio(activeIndex.value);
- nextTick(() => {
- scrollToCenterRatio(
- indexToRatio(activeIndex.value),
- "smooth",
- activeIndex.value
- );
- clampTooltipToScrollArea();
- });
- },
- { immediate: true }
- );
- watch(showHandleTooltip, (v) => {
- if (v) clampTooltipToScrollArea();
- });
- watch(
- () => handleRatio.value,
- () => {
- if (showHandleTooltip.value) clampTooltipToScrollArea();
- }
- );
- let resizeObserver = null;
- const handleStyle = computed(() => ({
- left: `${handleRatio.value * 100}%`,
- }));
- const tooltipBubbleStyle = computed(() => ({
- transform: `translateX(${tooltipShiftPx.value}px)`,
- }));
- /**
- * 以滚动可视区为边界,将气泡水平平移最小距离使其完整可见;箭头留在手柄中心(见模板结构)。
- */
- function clampTooltipToScrollArea() {
- if (!showHandleTooltip.value) return;
- const scrollEl = scrollRef.value;
- const bubbleEl = tooltipBubbleRef.value;
- if (!scrollEl || !bubbleEl) return;
- tooltipShiftPx.value = 0;
- nextTick(() => {
- requestAnimationFrame(() => {
- const b = scrollEl.getBoundingClientRect();
- const t = bubbleEl.getBoundingClientRect();
- const pad = TOOLTIP_EDGE_MARGIN_PX;
- const sLow = b.left + pad - t.left;
- const sHigh = b.right - pad - t.right;
- let s = 0;
- if (sLow <= sHigh) {
- if (sLow <= 0 && sHigh >= 0) s = 0;
- else if (sLow > 0) s = sLow;
- else s = sHigh;
- } else {
- s = (sLow + sHigh) / 2;
- }
- tooltipShiftPx.value = s;
- });
- });
- }
- let dragging = false;
- let activePointerId = null;
- /** 本次按下后是否发生过 pointermove(视为“移动过”) */
- let movedDuringHandleDrag = false;
- function getTrackRect() {
- return trackRef.value?.getBoundingClientRect() || null;
- }
- function clientXToRatio(clientX) {
- const rect = getTrackRect();
- if (!rect || rect.width <= 0) return handleRatio.value;
- const x = clientX - rect.left;
- const r = x / rect.width;
- return Math.min(Math.max(0, r), 1);
- }
- function onHandlePointerDown(e) {
- if (!trackRef.value) return;
- dragging = true;
- movedDuringHandleDrag = false;
- activePointerId = e.pointerId;
- try {
- handleRef.value?.setPointerCapture(e.pointerId);
- } catch (_) {
- /* ignore */
- }
- handleRatio.value = clientXToRatio(e.clientX);
- syncScrollToRatio(handleRatio.value);
- document.body.style.userSelect = "none";
- }
- function onPointerMove(e) {
- if (!dragging || e.pointerId !== activePointerId) return;
- movedDuringHandleDrag = true;
- handleRatio.value = clientXToRatio(e.clientX);
- syncScrollToRatio(handleRatio.value);
- if (showHandleTooltip.value) clampTooltipToScrollArea();
- }
- function onPointerUp(e) {
- if (!dragging || e.pointerId !== activePointerId) return;
- dragging = false;
- activePointerId = null;
- document.body.style.userSelect = "";
- try {
- handleRef.value?.releasePointerCapture(e.pointerId);
- } catch (_) {
- /* ignore */
- }
- const nextIdx = ratioToIndex(handleRatio.value);
- activeIndex.value = nextIdx;
- handleRatio.value = indexToRatio(nextIdx);
- syncScrollToRatio(handleRatio.value);
- if (movedDuringHandleDrag) {
- showHandleTooltip.value = false;
- }
- movedDuringHandleDrag = false;
- emit("update:modelValue", nextIdx);
- armScrollSettledAfterHandle();
- }
- /** 手柄松开后待「横向滚动结束」再向父组件派发 change / scrollSettled */
- let pendingScrollSettled = false;
- let scrollSettledFallbackTimer = null;
- let scrollSettledDebounceTimer = null;
- function clearScrollSettledTimers() {
- if (scrollSettledFallbackTimer != null) {
- clearTimeout(scrollSettledFallbackTimer);
- scrollSettledFallbackTimer = null;
- }
- if (scrollSettledDebounceTimer != null) {
- clearTimeout(scrollSettledDebounceTimer);
- scrollSettledDebounceTimer = null;
- }
- }
- function emitScrollSettledIfPending() {
- if (!pendingScrollSettled) return;
- pendingScrollSettled = false;
- clearScrollSettledTimers();
- const idx = activeIndex.value;
- const stage = normalizedStages.value[idx];
- const phenophaseCode = stage?.phenophase_code;
- emit("change", idx, stage, phenophaseCode);
- emit("scrollSettled", idx, stage, phenophaseCode);
- }
- function armScrollSettledAfterHandle() {
- pendingScrollSettled = true;
- clearScrollSettledTimers();
- scrollSettledFallbackTimer = setTimeout(() => {
- scrollSettledFallbackTimer = null;
- emitScrollSettledIfPending();
- }, 200);
- }
- function onScrollAreaScroll() {
- if (showHandleTooltip.value) clampTooltipToScrollArea();
- if (!pendingScrollSettled) return;
- if (scrollSettledDebounceTimer != null) {
- clearTimeout(scrollSettledDebounceTimer);
- }
- scrollSettledDebounceTimer = setTimeout(() => {
- scrollSettledDebounceTimer = null;
- emitScrollSettledIfPending();
- }, 80);
- }
- function onScrollAreaScrollEnd() {
- emitScrollSettledIfPending();
- }
- function onWindowResize() {
- if (showHandleTooltip.value) clampTooltipToScrollArea();
- }
- onMounted(() => {
- window.addEventListener("pointermove", onPointerMove);
- window.addEventListener("pointerup", onPointerUp);
- window.addEventListener("pointercancel", onPointerUp);
- window.addEventListener("resize", onWindowResize);
- const el = scrollRef.value;
- el?.addEventListener("scroll", onScrollAreaScroll, { passive: true });
- el?.addEventListener("scrollend", onScrollAreaScrollEnd, { passive: true });
- el?.addEventListener("wheel", onTimelineWheel, { passive: false });
- nextTick(() => clampTooltipToScrollArea());
- el?.addEventListener("touchstart", onScrollAreaTouchStart, {
- passive: true,
- });
- el?.addEventListener("touchmove", onScrollAreaTouchMove, {
- passive: false,
- });
- resizeObserver =
- typeof ResizeObserver !== "undefined"
- ? new ResizeObserver(() => {
- nextTick(() => {
- scrollToCenterRatio(
- indexToRatio(activeIndex.value),
- "auto",
- activeIndex.value
- );
- clampTooltipToScrollArea();
- });
- })
- : null;
- if (el && resizeObserver) {
- resizeObserver.observe(el);
- }
- });
- onBeforeUnmount(() => {
- window.removeEventListener("pointermove", onPointerMove);
- window.removeEventListener("pointerup", onPointerUp);
- window.removeEventListener("pointercancel", onPointerUp);
- window.removeEventListener("resize", onWindowResize);
- const el = scrollRef.value;
- el?.removeEventListener("scroll", onScrollAreaScroll);
- el?.removeEventListener("scrollend", onScrollAreaScrollEnd);
- el?.removeEventListener("wheel", onTimelineWheel);
- el?.removeEventListener("touchstart", onScrollAreaTouchStart);
- el?.removeEventListener("touchmove", onScrollAreaTouchMove);
- resizeObserver?.disconnect();
- resizeObserver = null;
- cancelScrollAnimation();
- clearScrollSettledTimers();
- pendingScrollSettled = false;
- });
- </script>
- <style scoped lang="scss">
- .growth-stage-timeline {
- width: 100%;
- overflow: hidden;
- }
- .growth-stage-timeline__scroll {
- overflow-x: hidden;
- overflow-y: visible;
- touch-action: pan-y;
- overscroll-behavior-x: none;
- -webkit-overflow-scrolling: touch;
- }
- .growth-stage-timeline__inner {
- display: flex;
- flex-direction: column;
- gap: 0;
- padding-top: 36px;
- padding-bottom: 8px;
- width: 100%;
- }
- .growth-stage-timeline__bg {
- display: grid;
- align-items: stretch;
- width: 100%;
- margin-top: 8px;
- gap: 0;
- }
- .growth-stage-timeline__bg-cell {
- background: #f5f5f5;
- padding: 12px 8px;
- text-align: left;
- box-sizing: border-box;
- border: none;
- border-top: 1px solid #d9d9d9;
- border-radius: 8px;
- min-width: 0;
- margin: 0 2px;
- transition: background-color 0.2s, border-color 0.2s;
- &:first-child {
- margin-left: 0;
- }
- &:last-child {
- margin-right: 0;
- }
- &--period {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: flex-start;
- gap: 3px;
- }
- &--active {
- background: #e6f4ff;
- border-top-width: 3px;
- border-top-color: #1890ff;
- .growth-stage-timeline__period-title {
- color: #1d2129;
- font-weight: 600;
- }
- .growth-stage-timeline__period-sub {
- color: #666;
- }
- }
- &:not(&--active) {
- .growth-stage-timeline__period-title {
- color: #8c8c8c;
- font-weight: 500;
- }
- .growth-stage-timeline__period-sub {
- color: #bfbfbf;
- }
- }
- }
- .growth-stage-timeline__period-title {
- font-size: 15px;
- font-weight: 600;
- color: #1d2129;
- text-align: left;
- max-width: 100%;
- word-break: break-word;
- }
- .growth-stage-timeline__period-sub {
- font-size: 12px;
- color: #666;
- text-align: left;
- max-width: 100%;
- word-break: break-word;
- white-space: normal;
- }
- .growth-stage-timeline__track {
- position: relative;
- min-height: 48px;
- }
- .growth-stage-timeline__track-line {
- position: absolute;
- left: 0;
- right: 0;
- top: 50%;
- height: 1px;
- margin-top: -0.5px;
- background: #e8e8e8;
- pointer-events: none;
- z-index: 0;
- }
- .growth-stage-timeline__track-grid {
- position: relative;
- z-index: 1;
- display: grid;
- align-items: center;
- min-height: 48px;
- width: 100%;
- }
- .growth-stage-timeline__dot-wrap {
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
- pointer-events: none;
- }
- .growth-stage-timeline__dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: #CDCDCD;
- border: 1px solid #fff;
- box-sizing: border-box;
- box-shadow: 0px 2px 3px 0px #00000012;
- }
- .growth-stage-timeline__handle {
- position: absolute;
- top: 50%;
- z-index: 2;
- transform: translate(-50%, -50%);
- touch-action: none;
- cursor: grab;
- &:active {
- cursor: grabbing;
- }
- }
- .growth-stage-timeline__handle-core {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .growth-stage-timeline__tooltip-wrap {
- position: absolute;
- left: 50%;
- bottom: 100%;
- transform: translateX(-50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- margin-bottom: 8px;
- pointer-events: none;
- z-index: 3;
- }
- .growth-stage-timeline__tooltip {
- max-width: min(240px, calc(100vw - 24px));
- white-space: nowrap;
- padding: 6px 12px;
- border-radius: 999px;
- background: #8c8c8c;
- color: #fff;
- font-size: 11px;
- line-height: 1.4;
- text-align: center;
- }
- .growth-stage-timeline__tooltip-caret {
- width: 0;
- height: 0;
- margin-top: -1px;
- border-width: 5px 5px 0 5px;
- border-style: solid;
- border-color: #8c8c8c transparent transparent transparent;
- }
- .growth-stage-timeline__handle-body {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- gap: 2px;
- width: 16px;
- height: 30px;
- border-radius: 7px;
- background: #1890ff;
- box-shadow: 0 2px 6px rgba(24, 144, 255, 0.35);
- transition: width 0.2s;
- &--wide {
- width: 48px;
- gap: 4px;
- border-radius: 10px;
- }
- }
- .growth-stage-timeline__handle-bar {
- display: block;
- width: 2px;
- height: 10px;
- border-radius: 1px;
- background: #fff;
- }
- .growth-stage-timeline__labels {
- display: grid;
- margin-top: 2px;
- gap: 0;
- width: 100%;
- }
- .growth-stage-timeline__label-col {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- padding: 0 4px 4px;
- box-sizing: border-box;
- min-width: 0;
- &--wide {
- .growth-stage-timeline__label-text {
- max-width: 100%;
- white-space: nowrap;
- }
- }
- }
- .growth-stage-timeline__label-text {
- font-size: 11px;
- color: #bfbfbf;
- width: max-content;
- word-break: break-all;
- transition: color 0.2s, font-size 0.2s;
- &--active {
- font-size: 14px;
- color: #1890ff;
- font-weight: 600;
- }
- }
- .growth-stage-timeline__tags {
- margin-top: 6px;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- width: 100%;
- }
- .growth-stage-timeline__tag {
- display: inline-block;
- max-width: 100%;
- padding: 2px 6px;
- border-radius: 4px;
- background: #00a870;
- color: #fff;
- font-size: 10px;
- line-height: 1.3;
- word-break: break-all;
- }
- </style>
|