GrowthStageTimeline.vue 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  1. <template>
  2. <div class="growth-stage-timeline">
  3. <div class="growth-stage-timeline__scroll" ref="scrollRef">
  4. <div class="growth-stage-timeline__inner" :style="innerStyle">
  5. <!-- 轨道:横线 + 节点 + 拖动手柄 -->
  6. <div class="growth-stage-timeline__track" ref="trackRef">
  7. <div class="growth-stage-timeline__track-line" aria-hidden="true"></div>
  8. <div
  9. class="growth-stage-timeline__track-grid"
  10. :style="{ gridTemplateColumns: gridCols }"
  11. >
  12. <div
  13. v-for="(_, i) in normalizedStages"
  14. :key="'dot-' + i"
  15. class="growth-stage-timeline__dot-wrap"
  16. >
  17. <span class="growth-stage-timeline__dot"></span>
  18. </div>
  19. </div>
  20. <div
  21. ref="handleRef"
  22. class="growth-stage-timeline__handle"
  23. :style="handleStyle"
  24. @pointerdown.stop.prevent="onHandlePointerDown"
  25. >
  26. <div class="growth-stage-timeline__handle-core">
  27. <div
  28. v-if="showHandleTooltip"
  29. class="growth-stage-timeline__tooltip-wrap"
  30. >
  31. <div
  32. ref="tooltipBubbleRef"
  33. class="growth-stage-timeline__tooltip"
  34. :style="tooltipBubbleStyle"
  35. >
  36. {{ displayTooltipText }}
  37. </div>
  38. <div
  39. class="growth-stage-timeline__tooltip-caret"
  40. aria-hidden="true"
  41. ></div>
  42. </div>
  43. <div
  44. class="growth-stage-timeline__handle-body"
  45. :class="{
  46. 'growth-stage-timeline__handle-body--wide':
  47. activeLabelIsShort,
  48. }"
  49. >
  50. <span class="growth-stage-timeline__handle-bar"></span>
  51. <span class="growth-stage-timeline__handle-bar"></span>
  52. <span class="growth-stage-timeline__handle-bar"></span>
  53. </div>
  54. </div>
  55. </div>
  56. </div>
  57. <!-- 阶段文案 + 绿色标签 -->
  58. <div
  59. class="growth-stage-timeline__labels"
  60. :style="{ gridTemplateColumns: gridCols }"
  61. >
  62. <div
  63. v-for="(stage, i) in normalizedStages"
  64. :key="'lb-' + i"
  65. class="growth-stage-timeline__label-col"
  66. :class="{
  67. 'growth-stage-timeline__label-col--wide':
  68. isShortStageLabel(stage.label),
  69. }"
  70. >
  71. <div
  72. class="growth-stage-timeline__label-text"
  73. :class="{
  74. 'growth-stage-timeline__label-text--active':
  75. i === activeIndex,
  76. }"
  77. >
  78. {{ stage.label }}
  79. </div>
  80. <div
  81. v-if="stage.tags && stage.tags.length"
  82. class="growth-stage-timeline__tags"
  83. >
  84. <span
  85. v-for="(tag, ti) in stage.tags"
  86. :key="ti"
  87. class="growth-stage-timeline__tag"
  88. >{{ tag }}</span>
  89. </div>
  90. </div>
  91. </div>
  92. <!-- 生育期卡片:与上方阶段列同网格对齐,随横向滚动联动 -->
  93. <div
  94. class="growth-stage-timeline__bg"
  95. :style="{ gridTemplateColumns: gridCols }"
  96. >
  97. <div
  98. v-for="(run, ri) in periodRuns"
  99. :key="'bg-run-' + ri"
  100. class="growth-stage-timeline__bg-cell growth-stage-timeline__bg-cell--period"
  101. :class="{
  102. 'growth-stage-timeline__bg-cell--active':
  103. ri === activePeriodRunIndex,
  104. }"
  105. :style="{ gridColumn: `span ${run.span}` }"
  106. >
  107. <div class="growth-stage-timeline__period-title">
  108. {{ run.periodTitle }}
  109. </div>
  110. <div class="growth-stage-timeline__period-sub">
  111. {{ run.periodSubtitle }}
  112. </div>
  113. </div>
  114. </div>
  115. </div>
  116. </div>
  117. </div>
  118. </template>
  119. <script setup>
  120. import {
  121. computed,
  122. ref,
  123. watch,
  124. onMounted,
  125. onBeforeUnmount,
  126. nextTick,
  127. } from "vue";
  128. import { useI18n } from "@/i18n";
  129. /**
  130. * @typedef {Object} GrowthStageItem
  131. * @property {string} label 节点文案,如「60%展开」
  132. * @property {string[]} [tags] 绿色标签,如 ['最佳给肥点']
  133. * @property {string} periodTitle 生育期大标题
  134. * @property {string} periodSubtitle 生育期描述小字
  135. * @property {string} [phenophase_code] 物候期编码,由接口/父组件写入
  136. */
  137. const props = defineProps({
  138. /** @type {import('vue').PropType<GrowthStageItem[]>} */
  139. stages: {
  140. type: Array,
  141. default: () => [],
  142. },
  143. /** 当前选中的阶段索引(与 v-model 同步);不传时为中间一档 */
  144. modelValue: {
  145. type: Number,
  146. default: undefined,
  147. },
  148. tooltipText: {
  149. type: String,
  150. default: "此为预估进程,请左右移动进行校准!",
  151. },
  152. /** 每列最小宽度(px),列数多时可横向滚动 */
  153. minColWidth: {
  154. type: Number,
  155. default: 72,
  156. },
  157. });
  158. const emit = defineEmits(["update:modelValue", "change", "scrollSettled", "locale-change"]);
  159. const { t, locale } = useI18n();
  160. const DEFAULT_TOOLTIP_ZH = "此为预估进程,请左右移动进行校准!";
  161. const displayTooltipText = computed(() => {
  162. if (!props.tooltipText || props.tooltipText === DEFAULT_TOOLTIP_ZH) {
  163. return t("growthStageTimeline.tooltipHint");
  164. }
  165. return t(props.tooltipText);
  166. });
  167. watch(locale, () => {
  168. emit("locale-change");
  169. });
  170. const scrollRef = ref(null);
  171. const trackRef = ref(null);
  172. const handleRef = ref(null);
  173. const tooltipBubbleRef = ref(null);
  174. /** 气泡相对手柄水平中心的额外偏移(px),用于避免贴边时被 overflow 裁切 */
  175. const tooltipShiftPx = ref(0);
  176. const TOOLTIP_EDGE_MARGIN_PX = 8;
  177. /** 本次进入页面内展示;拖动手柄产生位移后关闭,下次路由/页面再进入会重新挂载并再次显示 */
  178. const showHandleTooltip = ref(true);
  179. const normalizedStages = computed(() =>
  180. Array.isArray(props.stages) ? props.stages : []
  181. );
  182. const colCount = computed(() =>
  183. Math.max(1, normalizedStages.value.length)
  184. );
  185. /** time_discribe 文案过短(<3 字)时,该列占 3 个普通点宽度,便于下方标签展示 */
  186. const SHORT_LABEL_MAX_LEN = 3;
  187. const SHORT_LABEL_COL_WEIGHT = 3;
  188. function isShortStageLabel(label) {
  189. return String(label ?? "").length < SHORT_LABEL_MAX_LEN;
  190. }
  191. function getStageColumnWeight(stage) {
  192. return isShortStageLabel(stage?.label) ? SHORT_LABEL_COL_WEIGHT : 1;
  193. }
  194. const columnWeights = computed(() =>
  195. normalizedStages.value.map((stage) => getStageColumnWeight(stage))
  196. );
  197. const totalColumnWeight = computed(() =>
  198. Math.max(
  199. 1,
  200. columnWeights.value.reduce((sum, weight) => sum + weight, 0)
  201. )
  202. );
  203. const gridCols = computed(() => {
  204. const stages = normalizedStages.value;
  205. if (!stages.length) {
  206. return `minmax(${props.minColWidth}px, 1fr)`;
  207. }
  208. return stages
  209. .map((stage) => {
  210. const weight = getStageColumnWeight(stage);
  211. return `minmax(${weight * props.minColWidth}px, ${weight}fr)`;
  212. })
  213. .join(" ");
  214. });
  215. /** 左右留白:手柄 translate(-50%) 与气泡在首尾否则会溢出滚动宽度,且气泡换行会拉高整列导致视觉上“掉下去” */
  216. const INNER_EDGE_PAD_PX = 12;
  217. const innerStyle = computed(() => {
  218. const minContentWidth = columnWeights.value.reduce(
  219. (sum, weight) => sum + weight * props.minColWidth,
  220. 0
  221. );
  222. return {
  223. boxSizing: "border-box",
  224. minWidth: `${minContentWidth + INNER_EDGE_PAD_PX * 2}px`,
  225. paddingLeft: `${INNER_EDGE_PAD_PX}px`,
  226. paddingRight: `${INNER_EDGE_PAD_PX}px`,
  227. };
  228. });
  229. function periodKey(stage) {
  230. const s = stage || {};
  231. return `${s.periodTitle || ""}\0${s.periodSubtitle || ""}`;
  232. }
  233. /** 连续相同生育期合并为一段,用于背景区 grid-column span */
  234. const periodRuns = computed(() => {
  235. const list = normalizedStages.value;
  236. if (!list.length) {
  237. return [
  238. {
  239. span: 1,
  240. periodTitle: "",
  241. periodSubtitle: "",
  242. },
  243. ];
  244. }
  245. const runs = [];
  246. let i = 0;
  247. while (i < list.length) {
  248. const key = periodKey(list[i]);
  249. let span = 1;
  250. let j = i + 1;
  251. while (j < list.length && periodKey(list[j]) === key) {
  252. span++;
  253. j++;
  254. }
  255. runs.push({
  256. span,
  257. periodTitle: list[i].periodTitle ?? "",
  258. periodSubtitle: list[i].periodSubtitle ?? "",
  259. });
  260. i = j;
  261. }
  262. return runs;
  263. });
  264. /** 生育期卡片列宽与上方阶段列对齐:每段占 span 列,fr 比例与阶段网格一致 */
  265. const periodGridCols = computed(() => {
  266. const runs = periodRuns.value;
  267. if (!runs.length) {
  268. return `minmax(${props.minColWidth}px, 1fr)`;
  269. }
  270. return runs
  271. .map(
  272. (run) =>
  273. `minmax(${run.span * props.minColWidth}px, ${run.span}fr)`
  274. )
  275. .join(" ");
  276. });
  277. function clampStageIndex(v) {
  278. const last = Math.max(0, colCount.value - 1);
  279. return Math.min(Math.max(0, v), last);
  280. }
  281. /** 项数为 n 时默认选中索引 floor((n-1)/2),偶数个时偏左中间 */
  282. function middleStageIndex() {
  283. const n = colCount.value;
  284. return Math.max(0, Math.floor((n - 1) / 2));
  285. }
  286. const activeIndex = ref(0);
  287. const activeLabelIsShort = computed(() =>
  288. isShortStageLabel(normalizedStages.value[activeIndex.value]?.label)
  289. );
  290. watch(
  291. [colCount, () => props.modelValue],
  292. () => {
  293. const mv = props.modelValue;
  294. const hasParentIndex = mv !== undefined && mv !== null;
  295. const next = hasParentIndex
  296. ? clampStageIndex(mv)
  297. : middleStageIndex();
  298. activeIndex.value = next;
  299. // 仅在没有父级索引时回写默认档,避免覆盖父组件按物候编码算好的下标
  300. if (!hasParentIndex) {
  301. nextTick(() => {
  302. emit("update:modelValue", next);
  303. });
  304. }
  305. },
  306. { immediate: true }
  307. );
  308. /** 手柄水平位置:0~1,对应轨道宽度 */
  309. const handleRatio = ref(0);
  310. function stageIndexToPeriodRunIndex(stageIdx) {
  311. let start = 0;
  312. for (let ri = 0; ri < periodRuns.value.length; ri++) {
  313. const span = periodRuns.value[ri].span;
  314. if (stageIdx >= start && stageIdx < start + span) {
  315. return ri;
  316. }
  317. start += span;
  318. }
  319. return 0;
  320. }
  321. /** 视口中心(手柄)所在生育期段索引,随滚动/拖动实时高亮 */
  322. const activePeriodRunIndex = computed(() =>
  323. stageIndexToPeriodRunIndex(ratioToIndex(handleRatio.value))
  324. );
  325. function indexToRatio(idx) {
  326. const weights = columnWeights.value;
  327. const total = totalColumnWeight.value;
  328. if (weights.length <= 1) return 0.5;
  329. let start = 0;
  330. for (let i = 0; i < idx; i++) {
  331. start += weights[i] ?? 1;
  332. }
  333. const center = start + (weights[idx] ?? 1) / 2;
  334. return center / total;
  335. }
  336. function ratioToIndex(r) {
  337. const weights = columnWeights.value;
  338. const total = totalColumnWeight.value;
  339. const n = weights.length;
  340. if (n <= 1) return 0;
  341. const target = Math.min(Math.max(0, r), 1) * total;
  342. let acc = 0;
  343. let bestIdx = 0;
  344. let bestDist = Infinity;
  345. for (let i = 0; i < n; i++) {
  346. const center = acc + (weights[i] ?? 1) / 2;
  347. const dist = Math.abs(target - center);
  348. if (dist < bestDist) {
  349. bestDist = dist;
  350. bestIdx = i;
  351. }
  352. acc += weights[i] ?? 1;
  353. }
  354. return bestIdx;
  355. }
  356. /** 禁止在轨道上横向拖滚:仅在手柄拖动时由脚本更新 scrollLeft */
  357. let scrollAreaTouchStartX = 0;
  358. let scrollAreaTouchStartY = 0;
  359. function onScrollAreaTouchStart(e) {
  360. if (!e.touches?.length) return;
  361. scrollAreaTouchStartX = e.touches[0].clientX;
  362. scrollAreaTouchStartY = e.touches[0].clientY;
  363. }
  364. function onScrollAreaTouchMove(e) {
  365. if (!e.touches?.length) return;
  366. const dx = e.touches[0].clientX - scrollAreaTouchStartX;
  367. const dy = e.touches[0].clientY - scrollAreaTouchStartY;
  368. if (Math.abs(dx) > Math.abs(dy)) {
  369. e.preventDefault();
  370. }
  371. }
  372. function getScrollLayout() {
  373. const scrollEl = scrollRef.value;
  374. if (!scrollEl) return null;
  375. const inner = scrollEl.querySelector(".growth-stage-timeline__inner");
  376. if (!inner) return null;
  377. const w = inner.offsetWidth;
  378. const cw = scrollEl.clientWidth;
  379. const maxScroll = Math.max(0, scrollEl.scrollWidth - cw);
  380. const pad = INNER_EDGE_PAD_PX;
  381. const contentW = Math.max(0, w - pad * 2);
  382. return { scrollEl, cw, maxScroll, pad, contentW };
  383. }
  384. /** 滚动缓动系数,越小滚动越慢(0~1) */
  385. const SCROLL_LERP = 0.08;
  386. let scrollTargetLeft = null;
  387. let scrollAnimRafId = null;
  388. function getPeriodRunBounds(runIdx) {
  389. let stageStart = 0;
  390. for (let ri = 0; ri < runIdx; ri++) {
  391. stageStart += periodRuns.value[ri].span;
  392. }
  393. const span = periodRuns.value[runIdx]?.span ?? 1;
  394. const weights = columnWeights.value;
  395. const total = totalColumnWeight.value;
  396. if (!weights.length) {
  397. return { startRatio: 0, endRatio: 1, centerRatio: 0.5 };
  398. }
  399. let weightStart = 0;
  400. for (let i = 0; i < stageStart; i++) {
  401. weightStart += weights[i] ?? 1;
  402. }
  403. let weightSpan = 0;
  404. for (let i = stageStart; i < stageStart + span; i++) {
  405. weightSpan += weights[i] ?? 1;
  406. }
  407. return {
  408. startRatio: weightStart / total,
  409. endRatio: (weightStart + weightSpan) / total,
  410. centerRatio: (weightStart + weightSpan / 2) / total,
  411. };
  412. }
  413. function ratioToScrollLeft(ratio, stageIdx = null) {
  414. const m = getScrollLayout();
  415. if (!m) return null;
  416. const { cw, maxScroll, pad, contentW } = m;
  417. const r = Math.min(Math.max(0, ratio), 1);
  418. const handleCenterX = pad + r * contentW;
  419. const idx =
  420. stageIdx != null
  421. ? clampStageIndex(stageIdx)
  422. : ratioToIndex(ratio);
  423. const runIdx = stageIndexToPeriodRunIndex(idx);
  424. const { startRatio, endRatio } = getPeriodRunBounds(runIdx);
  425. const periodLeft = pad + startRatio * contentW;
  426. const periodRight = pad + endRatio * contentW;
  427. const periodWidth = periodRight - periodLeft;
  428. let left = handleCenterX - cw / 2;
  429. if (periodWidth <= cw) {
  430. if (periodLeft < left) left = periodLeft;
  431. if (periodRight > left + cw) left = periodRight - cw;
  432. } else {
  433. left = Math.max(periodLeft, Math.min(left, periodRight - cw));
  434. }
  435. return Math.max(0, Math.min(left, maxScroll));
  436. }
  437. function cancelScrollAnimation() {
  438. if (scrollAnimRafId != null) {
  439. cancelAnimationFrame(scrollAnimRafId);
  440. scrollAnimRafId = null;
  441. }
  442. }
  443. function runScrollAnimation() {
  444. const scrollEl = scrollRef.value;
  445. if (!scrollEl || scrollTargetLeft == null) {
  446. scrollAnimRafId = null;
  447. return;
  448. }
  449. const current = scrollEl.scrollLeft;
  450. const diff = scrollTargetLeft - current;
  451. if (Math.abs(diff) < 0.5) {
  452. scrollEl.scrollLeft = scrollTargetLeft;
  453. scrollTargetLeft = null;
  454. scrollAnimRafId = null;
  455. return;
  456. }
  457. scrollEl.scrollLeft = current + diff * SCROLL_LERP;
  458. scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
  459. }
  460. function applyScrollToRatio(ratio, immediate = false, stageIdx = null) {
  461. const left = ratioToScrollLeft(ratio, stageIdx);
  462. if (left == null) return;
  463. const scrollEl = scrollRef.value;
  464. if (!scrollEl) return;
  465. scrollTargetLeft = left;
  466. if (immediate) {
  467. cancelScrollAnimation();
  468. scrollEl.scrollLeft = left;
  469. scrollTargetLeft = null;
  470. return;
  471. }
  472. if (scrollAnimRafId == null) {
  473. scrollAnimRafId = requestAnimationFrame(runScrollAnimation);
  474. }
  475. }
  476. /** 将轨道上的比例位置(0~1,与手柄 left 一致)滚到视口水平中央 */
  477. function scrollToCenterRatio(ratio, behavior = "smooth", stageIdx = null) {
  478. applyScrollToRatio(ratio, behavior === "auto", stageIdx);
  479. }
  480. function syncScrollToRatio(ratio, immediate = false) {
  481. applyScrollToRatio(ratio, immediate, ratioToIndex(ratio));
  482. }
  483. function onTimelineWheel(e) {
  484. if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
  485. e.preventDefault();
  486. }
  487. }
  488. watch(
  489. [activeIndex, colCount],
  490. () => {
  491. handleRatio.value = indexToRatio(activeIndex.value);
  492. nextTick(() => {
  493. scrollToCenterRatio(
  494. indexToRatio(activeIndex.value),
  495. "smooth",
  496. activeIndex.value
  497. );
  498. clampTooltipToScrollArea();
  499. });
  500. },
  501. { immediate: true }
  502. );
  503. watch(showHandleTooltip, (v) => {
  504. if (v) clampTooltipToScrollArea();
  505. });
  506. watch(
  507. () => handleRatio.value,
  508. () => {
  509. if (showHandleTooltip.value) clampTooltipToScrollArea();
  510. }
  511. );
  512. let resizeObserver = null;
  513. const handleStyle = computed(() => ({
  514. left: `${handleRatio.value * 100}%`,
  515. }));
  516. const tooltipBubbleStyle = computed(() => ({
  517. transform: `translateX(${tooltipShiftPx.value}px)`,
  518. }));
  519. /**
  520. * 以滚动可视区为边界,将气泡水平平移最小距离使其完整可见;箭头留在手柄中心(见模板结构)。
  521. */
  522. function clampTooltipToScrollArea() {
  523. if (!showHandleTooltip.value) return;
  524. const scrollEl = scrollRef.value;
  525. const bubbleEl = tooltipBubbleRef.value;
  526. if (!scrollEl || !bubbleEl) return;
  527. tooltipShiftPx.value = 0;
  528. nextTick(() => {
  529. requestAnimationFrame(() => {
  530. const b = scrollEl.getBoundingClientRect();
  531. const t = bubbleEl.getBoundingClientRect();
  532. const pad = TOOLTIP_EDGE_MARGIN_PX;
  533. const sLow = b.left + pad - t.left;
  534. const sHigh = b.right - pad - t.right;
  535. let s = 0;
  536. if (sLow <= sHigh) {
  537. if (sLow <= 0 && sHigh >= 0) s = 0;
  538. else if (sLow > 0) s = sLow;
  539. else s = sHigh;
  540. } else {
  541. s = (sLow + sHigh) / 2;
  542. }
  543. tooltipShiftPx.value = s;
  544. });
  545. });
  546. }
  547. let dragging = false;
  548. let activePointerId = null;
  549. /** 本次按下后是否发生过 pointermove(视为“移动过”) */
  550. let movedDuringHandleDrag = false;
  551. function getTrackRect() {
  552. return trackRef.value?.getBoundingClientRect() || null;
  553. }
  554. function clientXToRatio(clientX) {
  555. const rect = getTrackRect();
  556. if (!rect || rect.width <= 0) return handleRatio.value;
  557. const x = clientX - rect.left;
  558. const r = x / rect.width;
  559. return Math.min(Math.max(0, r), 1);
  560. }
  561. function onHandlePointerDown(e) {
  562. if (!trackRef.value) return;
  563. dragging = true;
  564. movedDuringHandleDrag = false;
  565. activePointerId = e.pointerId;
  566. try {
  567. handleRef.value?.setPointerCapture(e.pointerId);
  568. } catch (_) {
  569. /* ignore */
  570. }
  571. handleRatio.value = clientXToRatio(e.clientX);
  572. syncScrollToRatio(handleRatio.value);
  573. document.body.style.userSelect = "none";
  574. }
  575. function onPointerMove(e) {
  576. if (!dragging || e.pointerId !== activePointerId) return;
  577. movedDuringHandleDrag = true;
  578. handleRatio.value = clientXToRatio(e.clientX);
  579. syncScrollToRatio(handleRatio.value);
  580. if (showHandleTooltip.value) clampTooltipToScrollArea();
  581. }
  582. function onPointerUp(e) {
  583. if (!dragging || e.pointerId !== activePointerId) return;
  584. dragging = false;
  585. activePointerId = null;
  586. document.body.style.userSelect = "";
  587. try {
  588. handleRef.value?.releasePointerCapture(e.pointerId);
  589. } catch (_) {
  590. /* ignore */
  591. }
  592. const nextIdx = ratioToIndex(handleRatio.value);
  593. activeIndex.value = nextIdx;
  594. handleRatio.value = indexToRatio(nextIdx);
  595. syncScrollToRatio(handleRatio.value);
  596. if (movedDuringHandleDrag) {
  597. showHandleTooltip.value = false;
  598. }
  599. movedDuringHandleDrag = false;
  600. emit("update:modelValue", nextIdx);
  601. armScrollSettledAfterHandle();
  602. }
  603. /** 手柄松开后待「横向滚动结束」再向父组件派发 change / scrollSettled */
  604. let pendingScrollSettled = false;
  605. let scrollSettledFallbackTimer = null;
  606. let scrollSettledDebounceTimer = null;
  607. function clearScrollSettledTimers() {
  608. if (scrollSettledFallbackTimer != null) {
  609. clearTimeout(scrollSettledFallbackTimer);
  610. scrollSettledFallbackTimer = null;
  611. }
  612. if (scrollSettledDebounceTimer != null) {
  613. clearTimeout(scrollSettledDebounceTimer);
  614. scrollSettledDebounceTimer = null;
  615. }
  616. }
  617. function emitScrollSettledIfPending() {
  618. if (!pendingScrollSettled) return;
  619. pendingScrollSettled = false;
  620. clearScrollSettledTimers();
  621. const idx = activeIndex.value;
  622. const stage = normalizedStages.value[idx];
  623. const phenophaseCode = stage?.phenophase_code;
  624. emit("change", idx, stage, phenophaseCode);
  625. emit("scrollSettled", idx, stage, phenophaseCode);
  626. }
  627. function armScrollSettledAfterHandle() {
  628. pendingScrollSettled = true;
  629. clearScrollSettledTimers();
  630. scrollSettledFallbackTimer = setTimeout(() => {
  631. scrollSettledFallbackTimer = null;
  632. emitScrollSettledIfPending();
  633. }, 200);
  634. }
  635. function onScrollAreaScroll() {
  636. if (showHandleTooltip.value) clampTooltipToScrollArea();
  637. if (!pendingScrollSettled) return;
  638. if (scrollSettledDebounceTimer != null) {
  639. clearTimeout(scrollSettledDebounceTimer);
  640. }
  641. scrollSettledDebounceTimer = setTimeout(() => {
  642. scrollSettledDebounceTimer = null;
  643. emitScrollSettledIfPending();
  644. }, 80);
  645. }
  646. function onScrollAreaScrollEnd() {
  647. emitScrollSettledIfPending();
  648. }
  649. function onWindowResize() {
  650. if (showHandleTooltip.value) clampTooltipToScrollArea();
  651. }
  652. onMounted(() => {
  653. window.addEventListener("pointermove", onPointerMove);
  654. window.addEventListener("pointerup", onPointerUp);
  655. window.addEventListener("pointercancel", onPointerUp);
  656. window.addEventListener("resize", onWindowResize);
  657. const el = scrollRef.value;
  658. el?.addEventListener("scroll", onScrollAreaScroll, { passive: true });
  659. el?.addEventListener("scrollend", onScrollAreaScrollEnd, { passive: true });
  660. el?.addEventListener("wheel", onTimelineWheel, { passive: false });
  661. nextTick(() => clampTooltipToScrollArea());
  662. el?.addEventListener("touchstart", onScrollAreaTouchStart, {
  663. passive: true,
  664. });
  665. el?.addEventListener("touchmove", onScrollAreaTouchMove, {
  666. passive: false,
  667. });
  668. resizeObserver =
  669. typeof ResizeObserver !== "undefined"
  670. ? new ResizeObserver(() => {
  671. nextTick(() => {
  672. scrollToCenterRatio(
  673. indexToRatio(activeIndex.value),
  674. "auto",
  675. activeIndex.value
  676. );
  677. clampTooltipToScrollArea();
  678. });
  679. })
  680. : null;
  681. if (el && resizeObserver) {
  682. resizeObserver.observe(el);
  683. }
  684. });
  685. onBeforeUnmount(() => {
  686. window.removeEventListener("pointermove", onPointerMove);
  687. window.removeEventListener("pointerup", onPointerUp);
  688. window.removeEventListener("pointercancel", onPointerUp);
  689. window.removeEventListener("resize", onWindowResize);
  690. const el = scrollRef.value;
  691. el?.removeEventListener("scroll", onScrollAreaScroll);
  692. el?.removeEventListener("scrollend", onScrollAreaScrollEnd);
  693. el?.removeEventListener("wheel", onTimelineWheel);
  694. el?.removeEventListener("touchstart", onScrollAreaTouchStart);
  695. el?.removeEventListener("touchmove", onScrollAreaTouchMove);
  696. resizeObserver?.disconnect();
  697. resizeObserver = null;
  698. cancelScrollAnimation();
  699. clearScrollSettledTimers();
  700. pendingScrollSettled = false;
  701. });
  702. </script>
  703. <style scoped lang="scss">
  704. .growth-stage-timeline {
  705. width: 100%;
  706. overflow: hidden;
  707. }
  708. .growth-stage-timeline__scroll {
  709. overflow-x: hidden;
  710. overflow-y: visible;
  711. touch-action: pan-y;
  712. overscroll-behavior-x: none;
  713. -webkit-overflow-scrolling: touch;
  714. }
  715. .growth-stage-timeline__inner {
  716. display: flex;
  717. flex-direction: column;
  718. gap: 0;
  719. padding-top: 36px;
  720. padding-bottom: 8px;
  721. width: 100%;
  722. }
  723. .growth-stage-timeline__bg {
  724. display: grid;
  725. align-items: stretch;
  726. width: 100%;
  727. margin-top: 8px;
  728. gap: 0;
  729. }
  730. .growth-stage-timeline__bg-cell {
  731. background: #f5f5f5;
  732. padding: 12px 8px;
  733. text-align: left;
  734. box-sizing: border-box;
  735. border: none;
  736. border-top: 1px solid #d9d9d9;
  737. border-radius: 8px;
  738. min-width: 0;
  739. margin: 0 2px;
  740. transition: background-color 0.2s, border-color 0.2s;
  741. &:first-child {
  742. margin-left: 0;
  743. }
  744. &:last-child {
  745. margin-right: 0;
  746. }
  747. &--period {
  748. display: flex;
  749. flex-direction: column;
  750. align-items: flex-start;
  751. justify-content: flex-start;
  752. gap: 3px;
  753. }
  754. &--active {
  755. background: #e6f4ff;
  756. border-top-width: 3px;
  757. border-top-color: #1890ff;
  758. .growth-stage-timeline__period-title {
  759. color: #1d2129;
  760. font-weight: 600;
  761. }
  762. .growth-stage-timeline__period-sub {
  763. color: #666;
  764. }
  765. }
  766. &:not(&--active) {
  767. .growth-stage-timeline__period-title {
  768. color: #8c8c8c;
  769. font-weight: 500;
  770. }
  771. .growth-stage-timeline__period-sub {
  772. color: #bfbfbf;
  773. }
  774. }
  775. }
  776. .growth-stage-timeline__period-title {
  777. font-size: 15px;
  778. font-weight: 600;
  779. color: #1d2129;
  780. text-align: left;
  781. max-width: 100%;
  782. word-break: break-word;
  783. }
  784. .growth-stage-timeline__period-sub {
  785. font-size: 12px;
  786. color: #666;
  787. text-align: left;
  788. max-width: 100%;
  789. word-break: break-word;
  790. white-space: normal;
  791. }
  792. .growth-stage-timeline__track {
  793. position: relative;
  794. min-height: 48px;
  795. }
  796. .growth-stage-timeline__track-line {
  797. position: absolute;
  798. left: 0;
  799. right: 0;
  800. top: 50%;
  801. height: 1px;
  802. margin-top: -0.5px;
  803. background: #e8e8e8;
  804. pointer-events: none;
  805. z-index: 0;
  806. }
  807. .growth-stage-timeline__track-grid {
  808. position: relative;
  809. z-index: 1;
  810. display: grid;
  811. align-items: center;
  812. min-height: 48px;
  813. width: 100%;
  814. }
  815. .growth-stage-timeline__dot-wrap {
  816. position: relative;
  817. display: flex;
  818. justify-content: center;
  819. align-items: center;
  820. pointer-events: none;
  821. }
  822. .growth-stage-timeline__dot {
  823. width: 10px;
  824. height: 10px;
  825. border-radius: 50%;
  826. background: #CDCDCD;
  827. border: 1px solid #fff;
  828. box-sizing: border-box;
  829. box-shadow: 0px 2px 3px 0px #00000012;
  830. }
  831. .growth-stage-timeline__handle {
  832. position: absolute;
  833. top: 50%;
  834. z-index: 2;
  835. transform: translate(-50%, -50%);
  836. touch-action: none;
  837. cursor: grab;
  838. &:active {
  839. cursor: grabbing;
  840. }
  841. }
  842. .growth-stage-timeline__handle-core {
  843. position: relative;
  844. display: flex;
  845. flex-direction: column;
  846. align-items: center;
  847. }
  848. .growth-stage-timeline__tooltip-wrap {
  849. position: absolute;
  850. left: 50%;
  851. bottom: 100%;
  852. transform: translateX(-50%);
  853. display: flex;
  854. flex-direction: column;
  855. align-items: center;
  856. margin-bottom: 8px;
  857. pointer-events: none;
  858. z-index: 3;
  859. }
  860. .growth-stage-timeline__tooltip {
  861. max-width: min(240px, calc(100vw - 24px));
  862. white-space: nowrap;
  863. padding: 6px 12px;
  864. border-radius: 999px;
  865. background: #8c8c8c;
  866. color: #fff;
  867. font-size: 11px;
  868. line-height: 1.4;
  869. text-align: center;
  870. }
  871. .growth-stage-timeline__tooltip-caret {
  872. width: 0;
  873. height: 0;
  874. margin-top: -1px;
  875. border-width: 5px 5px 0 5px;
  876. border-style: solid;
  877. border-color: #8c8c8c transparent transparent transparent;
  878. }
  879. .growth-stage-timeline__handle-body {
  880. display: flex;
  881. flex-direction: row;
  882. align-items: center;
  883. justify-content: center;
  884. gap: 2px;
  885. width: 16px;
  886. height: 30px;
  887. border-radius: 7px;
  888. background: #1890ff;
  889. box-shadow: 0 2px 6px rgba(24, 144, 255, 0.35);
  890. transition: width 0.2s;
  891. &--wide {
  892. width: 48px;
  893. gap: 4px;
  894. border-radius: 10px;
  895. }
  896. }
  897. .growth-stage-timeline__handle-bar {
  898. display: block;
  899. width: 2px;
  900. height: 10px;
  901. border-radius: 1px;
  902. background: #fff;
  903. }
  904. .growth-stage-timeline__labels {
  905. display: grid;
  906. margin-top: 2px;
  907. gap: 0;
  908. width: 100%;
  909. }
  910. .growth-stage-timeline__label-col {
  911. display: flex;
  912. flex-direction: column;
  913. align-items: center;
  914. text-align: center;
  915. padding: 0 4px 4px;
  916. box-sizing: border-box;
  917. min-width: 0;
  918. &--wide {
  919. .growth-stage-timeline__label-text {
  920. max-width: 100%;
  921. white-space: nowrap;
  922. }
  923. }
  924. }
  925. .growth-stage-timeline__label-text {
  926. font-size: 11px;
  927. color: #bfbfbf;
  928. width: max-content;
  929. word-break: break-all;
  930. transition: color 0.2s, font-size 0.2s;
  931. &--active {
  932. font-size: 14px;
  933. color: #1890ff;
  934. font-weight: 600;
  935. }
  936. }
  937. .growth-stage-timeline__tags {
  938. margin-top: 6px;
  939. display: flex;
  940. flex-direction: column;
  941. align-items: center;
  942. gap: 4px;
  943. width: 100%;
  944. }
  945. .growth-stage-timeline__tag {
  946. display: inline-block;
  947. max-width: 100%;
  948. padding: 2px 6px;
  949. border-radius: 4px;
  950. background: #00a870;
  951. color: #fff;
  952. font-size: 10px;
  953. line-height: 1.3;
  954. word-break: break-all;
  955. }
  956. </style>