vue3+d3js 历史年表

35 阅读8分钟

效果图

image.png

image.png

该功能分为地图和刻度尺两个模块,地图是使用了d3js,有缩放功能,刻度尺是直接使用了js,并且选中的时候可以弹窗形式展示详情信息,直接看源码

  1. 地图模块
<template>
  <div class="china-map" id="chinaMap"></div>
</template>
<script setup>
import defUrl from '@/assets/img/def.png'

const { proxy } = getCurrentInstance()
import * as d3 from 'd3'
import { geoPath } from 'd3-geo'
import mapSource from './china.json'
/* ------------------------------------------------变量----------------------------------------------------*/
const svgVo = reactive({
  width: 0,
  height: 0,
  vm: null,
  gVm: null,
  projectionVm: null
})

const mapColor = new Map([
  ['黑龙江省', '#C5E4D4'],
  ['内蒙古自治区', '#FFFDD7'],
  ['吉林省', '#F8C7DC'],
  ['辽宁省', '#C7CDE7'],
  ['河北省', '#F8C7DC'],
  ['北京市', '#FFFDD7'],
  ['天津市', '#FFFDD6'],
  ['上海市', '#F8C7DC'],
  ['山西省', '#C5E4D4'],
  ['山东省', '#FFFDD7'],
  ['河南省', '#FAE0BF'],
  ['安徽省', '#C5E4D4'],
  ['江苏省', '#F8C7DC'],
  ['浙江省', '#FFFDD7'],
  ['福建省', '#FAE0BF'],
  ['江西省', '#F8C7DC'],
  ['湖南省', '#FAE0BF'],
  ['湖北省', '#FFFDD7'],
  ['陕西省', '#F8C7DC'],
  ['宁夏回族自治区', '#FAE0BF'],
  ['甘肃省', '#C5E4D4'],
  ['四川省', '#FAE0BF'],
  ['重庆市', '#C7CDE7'],
  ['贵州省', '#C5E4D4'],
  ['广西壮族自治区', '#F8C7DC'],
  ['广东省', '#FFFDD7'],
  ['海南省', '#FAE0BF'],
  ['台湾省', '#C5E4D4'],
  ['云南省', '#FFFDD7'],
  ['青海省', '#C7CDE7'],
  ['西藏自治区', '#F8C7DC'],
  ['新疆维吾尔自治区', '#FAE0BF'],
  ['香港特别行政区', '#F8C7DC'],
  ['澳门特别行政区', '#C5E4D4']
])

/* ----------------------------------------------事件函数--------------------------------------------------*/
const emit = defineEmits(['change'])

// 百度转国测
const BD09ToWGS84 = ([lng, lat]) => {
  return gcoord.transform(
    [lng, lat], // 经纬度坐标
    gcoord.BD09, // 当前坐标系
    gcoord.WGS84 // 目标坐标系
  )
}
const removePoi = () => {
  svgVo.gVm.selectAll('.poi-text-group').remove()
}
// 创建带图片的坐标点
const createPoiImg = ([x, y], info) => {
  const g = svgVo.gVm
    .append('g')
    .attr('id', 'ccca')
    .attr('class', 'poi-text-group')
    .attr('transform', `translate(${x},${y})`)
  //
  g.append('pattern')
    .attr('height', '1')
    .attr('width', '1')
    .attr('patternContentUnits', 'objectBoundingBox')
    .attr('id', 'poiImg')
    .append('image')
    .attr('height', '1')
    .attr('width', '1')
    .attr('preserveAspectRatio', 'none')
    .attr('xlink:href', info.imgUrl || defUrl)
    .style('object-fit', 'cover')

  g.append('marker')
    .attr('id', 'resolved')
    .attr('markerUnits', 'userSpaceOnUse')
    .attr('viewBox', '0 -5 10 10') // 坐标系的区域
    .attr('refX', 0) // 箭头坐标
    .attr('refY', 0)
    .attr('markerWidth', 20) // 标识的大小
    .attr('markerHeight', 10)
    .attr('orient', '90deg') // 绘制方向,可设定为:auto(自动确认方向)和 角度值
    .attr('stroke-width', 10) // 箭头宽度
    .append('path')
    .attr('d', 'M0,-5L10,0L0,5') // 箭头的路径
    .attr('fill', '#fff') // 箭头颜色

  g.append('line')
    .attr('x1', 0)
    .attr('y1', 0)
    .attr('x2', 0)
    .attr('y2', -10)
    .attr('stroke', 'rgba(255,255,255,0)')
    .attr('stroke-width', 5)
    .attr('marker-end', 'url(#resolved)')

  //
  g.append('circle')
    .attr('r', 20)
    .attr('cx', 0)
    .attr('cy', -28)
    .attr('stroke', '#fff')
    .attr('stroke-width', '3')
    .attr('fill', 'url(#poiImg)')
}
// 创建文字坐标点
const createPoiText = ([x, y], info) => {
  const { eventName: text } = info
  const fontSize = 12
  const padding = 4
  const rectW = text.length * fontSize + padding
  const rectX = -(rectW / 2)
  const g = svgVo.gVm
    .append('g')
    .attr('id', x + y + '')
    .attr('class', 'poi-text-group')
    .attr('transform', `translate(${x},${y})`)
    .attr('_x', x)
    .attr('_y', y)

  g.append('circle')
    .attr('r', 3)
    .attr('stroke', '#fff')
    .attr('stroke-width', '0.5')
    .attr('fill', 'rgba(223, 51, 0, 0.60)')
    .attr('cx', 0)
    .attr('cy', 0)
    .style('transform', 'rotateX(45deg)')

  g.append('marker')
    .attr('id', 'resolved')
    .attr('markerUnits', 'userSpaceOnUse')
    .attr('viewBox', '0 -5 10 10') // 坐标系的区域
    .attr('refX', 0) // 箭头坐标
    .attr('refY', 0)
    .attr('markerWidth', 10) // 标识的大小
    .attr('markerHeight', 5)
    .attr('orient', '90deg') // 绘制方向,可设定为:auto(自动确认方向)和 角度值
    .attr('stroke-width', 5) // 箭头宽度
    .append('path')
    .attr('d', 'M0,-5L10,0L0,5') // 箭头的路径
    .attr('fill', '#fff') // 箭头颜色

  g.append('line')
    .attr('x1', 0)
    .attr('y1', 0)
    .attr('x2', 0)
    .attr('y2', -4.5)
    .attr('stroke', 'rgba(255,255,255,0)')
    .attr('stroke-width', 5)
    .attr('marker-end', 'url(#resolved)')

  g.append('rect')
    .attr('rx', 5)
    .attr('fill', 'rgba(255, 255, 255, 1)')
    .attr('width', rectW)
    .attr('height', 16.5)
    .attr('x', rectX)
    .attr('y', -19)

  g.append('text')
    .attr('x', rectX + padding / 2)
    .attr('y', -6)
    .attr('fill', '#333333')
    .attr('font-size', fontSize)
    .text(text)
    .on('click', () => {
      emit('change', info.eventId)
    })
}
/**
 * @param info
 * @param isText true 文本 false 头像
 */
const createPoi = (info, isText = true) => {
  if (!info) {
    return
  }
  const newCp = svgVo.projectionVm(BD09ToWGS84([info.longitude, info.latitude]))
  if (isText) {
    createPoiText(newCp, info)
  } else {
    createPoiImg(newCp, info)
  }
}

const init = async () => {
  const projection = d3
    .geoMercator()
    .center([106.6113, 26.9385])
    .scale(350)
    .translate([svgVo.width / 2 + 10, svgVo.height - 70])
  svgVo.projectionVm = projection
  const _geoPath = geoPath(projection)

  svgVo.vm = d3
    .select('#chinaMap')
    .append('svg')
    .attr('id', 'chinaMapSvg')
    .attr('width', svgVo.width)
    .attr('height', svgVo.height)

  const g = svgVo.vm
    .append('g')
    .attr('id', 'mapG')
    .attr('width', 300)
    .attr('height', 300)
    .attr('translate', '0,0')
    .attr('scale', 1)
    .attr('height', 300)
    .attr('transform', 'translate(0,0) scale(1)')
  svgVo.gVm = g
  // 缩放
  const zoomd = d3
    .zoom()
    .scaleExtent([1, 5])
    .on('zoom', ({ transform }) => {
      const poiTextGroup = svgVo.gVm.selectAll('.poi-text-group')
      let k = 1 / transform.k
      // 放大就缩小marker
      poiTextGroup._groups.map((nodeList) => {
        for (let i = 0; i < nodeList.length; i++) {
          const _ = nodeList[i]
          _.setAttribute(
            'transform',
            `translate(${_.getAttribute('_x')},${_.getAttribute(
              '_y'
            )}) scale(${k})`
          )
        }
      })
      g.attr('translate', `${transform.x},${transform.y}`)
        .attr('scale', transform.k)
        .attr(
          'transform',
          `translate(${transform.x},${transform.y}) scale(${transform.k})`
        )
    })
  // 平移
  svgVo.vm.call(zoomd).on('dblclick.zoom', null)

  g.append('g')
    .selectAll('path')
    .data(mapSource.features)
    .enter()
    .append('path')
    .attr('stroke', 'gray')
    .attr('stroke-width', 1)
    .attr('d', (d) => {
      return _geoPath(d)
    })
    .attr('fill', (d) => {
      return mapColor.get(d.properties.name)
    })

  //绘制文字
  g.append('g')
    .selectAll('text')
    .data(mapSource.features)
    .enter()
    .append('text')
    .attr('font-size', 4)
    .attr('text-anchor', 'middle')
    .attr('x', (d, i) => {
      const position = projection(d.properties.cp || [0, 0])
      return position[0]
    })
    .attr('y', (d, i) => {
      const position = projection(d.properties.cp || [0, 0])
      return position[1]
    })
    .attr('dy', (d, i) => {
      // 这里为什么这么写呢,因为澳门和香港重合了,挤到一起了。
      if (d.properties.name === '澳门特别行政区') {
        return 10
      }
    })
    .text((d, i) => {
      return d.properties.name
    })
}
defineExpose({
  createPoi,
  removePoi
})
/* ----------------------------------------------生命周期--------------------------------------------------*/
onMounted(() => {
  proxy.$nextTick(() => {
    svgVo.width = document.getElementById('chinaMap').offsetWidth
    svgVo.height = document.getElementById('chinaMap').offsetHeight
    init()
  })
})
</script>
<style scoped lang="scss">
.china-map {
  height: 100%;
  width: 100%;
  svg {
    width: 100%;
    height: 100%;
  }
}
</style>
  1. 刻度尺
<template>
  <div class="chronological-table">
    <NavBar ref="pageContainer" fixed :share="false" />
    <header>
      <ChinaMap ref="chinaMap" @change="changePoi" />
    </header>
    <section
      class="hide-scroll"
      @touchstart="_touchstart"
      @touchend="_touchend"
      @touchmove="_touchmove"
      @scroll="scrollFn"
      ref="section"
    >
      <!--      比例尺 start-->
      <div class="rulers">
        <div class="left-ruler-line"></div>
        <div class="ruler" ref="ruler">
          <div class="ruler-ticks">
            <div
              class="ruler-tick"
              :class="[index % 12 === 0 && 'ruler-tick-big']"
              v-for="(tick, index) in state.ticks"
              :key="tick"
            >
              <span>{{
                index % 12 === 0 ? getNowTime(index * interval, false) : ''
              }}</span>
            </div>
          </div>
          <div class="ruler-line"></div>
        </div>
        <div class="right-ruler-line"></div>
      </div>
      <!--      比例尺 end-->
      <!--      事件内容 start-->
      <div class="list">
        <div class="list-item" v-for="(item, index) in list" :key="index">
          <div class="list-item-menu">
            <label>{{ item.name }}</label>
          </div>
          <div class="list-item-content" :style="listItemContentStyle(item)">
            <!--            年份区间段-->
            <!--占位 start-->
            <div class="placeholder"></div>
            <!--占位 end-->
            <div class="year-section">
              <div class="mask-text"></div>
              <div
                class="poi-text"
                :class="nowPoiText === info.id && 'now-poi-text'"
                v-for="(info, infoIndex) in item.eventList"
                :key="infoIndex + '-' + index"
                :style="poiTextStyle(info, info.yearInt, infoIndex)"
                @touchend.stop
                @click.stop="changeText(info, info.yearInt)"
              >
                {{ info.eventName }}
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
    <!--      事件内容 end-->
    <!--    中间指针 srart-->
    <div class="pointer">
      <p class="poi-x">{{ nowTime }}</p>
    </div>
    <!--    中间指针 end-->
  </div>
  <ActionSheet v-model="show" :topH="topH" :overlay="false">
    <template #default>
      <div class="sheet-content">
        <header>
          <img v-lazy="details.imgUrl" v-if="details.imgUrl" />
          <div class="right">
            <div class="right-title">{{ details.title }}</div>
            <div class="right-dsc">
              {{ details.subtitle }}
            </div>
          </div>
        </header>
        <div class="texts">
          <div class="text-item">
            <div class="dsc" v-html="details.summaryText"></div>
          </div>
        </div>
      </div>
    </template>
  </ActionSheet>
</template>
<script setup>
import NavBar from '@/components/navBar/index.vue'
import { useTouch } from '@/hooks/chartTool'
import ChinaMap from '@/views/chronologicalTable/ChinaMap.vue'
import { apiRedChinaMediaEventHistoryChronologyV2 } from '@/api'
import { useEventDetails } from '@/hooks/tool'
import ActionSheet from '@/components/ActionSheet.vue'
const { proxy } = getCurrentInstance()
/* ------------------------------------------------变量----------------------------------------------------*/
const show = ref(false)
const pageContainer = ref()
const topH = ref(0)
const tickStartTime = 1919
const tickEndTime = new Date().getFullYear()
const nowM = new Date().getMonth() + 1 // 当前所在月份
const month = (tickEndTime - tickStartTime) * 12 + nowM
const placeholderW = document.body.offsetWidth / 2 - 70 // 安全区域占位
const blockH = '120px' // 块高度
const interval = 10 // 刻度之间的间隔 1个刻度1月
const nowPoiText = ref(null) // 当前点击的事件
const state = reactive({
  timeScaleEvent: {}, // 同段时间下面的所有事件
  nowTime: '', // 当前的时间
  letGo: false, // 是否放开手了
  timeId: '',
  poiX: '',
  ticks: [], // 刻度尺的刻度值
  tickInterval: interval, // 刻度之间的间隔 1个刻度1月
  rulerLength: month * interval // 刻度尺的长度
})
const { details, getApiEventDetails, openUrl } = useEventDetails()

const list = ref([])
const poiXTimeId = ref('')

const nowTime = computed({
  get: () => getNowTime(state.poiX),
  set: (poiX) => getNowTime(poiX)
})
/* ------------------------------------------------事件函数----------------------------------------------------*/
const _touchstart = (e) => {
  touchAction.value = 'auto'
  state.letGo = false
  touchstart(e)
}
const _touchend = (e) => {
  touchend(e)
}
const touchAction = ref('auto')
const _touchmove = (e) => {
  touchMove(e)
}

const getList = async () => {
  list.value = await apiRedChinaMediaEventHistoryChronologyV2()
  getTimeScaleEvent()
  proxy.$nextTick(() => {
    list.value = list.value.map((item) => {
      item.startYear =
        Math.min.apply(
          Math,
          item.eventList.map((_) => _.yearInt)
        ) + ''
      item.startYearInfo =
        item.eventList[
          item.eventList.findIndex((_) => _.yearInt === item.startYear)
        ]
      return item
    })
    const minYear =
      Math.min.apply(
        Math,
        list.value.map((_) => _.startYear)
      ) + ''
    const minInfo =
      list.value[list.value.findIndex((_) => _.startYear === minYear)]
        .startYearInfo
    // 定位到最开始的一条数据
    proxy.$refs.section.scrollLeft = timeToPoiX(
      minInfo.yearInt,
      minInfo.monthStr,
      tickStartTime
    )
  })
}

// 同段时间下面的所有事件
const getTimeScaleEvent = () => {
  list.value.map((item) => {
    item.eventList.map((_) => {
      const y = _.yearInt + '年'
      const m = (_.monthStr ? Number(_.monthStr) : 1) + '月'
      const key = y + m
      // no-prototype-builtins
      if (!state.timeScaleEvent.hasOwnProperty(key)) {
        state.timeScaleEvent[key] = []
      }
      state.timeScaleEvent[key].push({
        ..._,
        parentInfo: item // 记录上一级
      })
    })
  })
}

// 根据时间拿到事件创建点
const timeTOEvent = (time) => {
  const arr = state.timeScaleEvent[time] || []
  proxy.$refs.chinaMap.removePoi()
  arr.map((item) => {
    proxy.$refs.chinaMap.createPoi(item)
  })
  arr.length && show.value && getApiEventDetails(arr[0].eventId)
}
/**
 * 时间换取poiX
 * @param time
 * @param M 月份 没有默认1月
 * @param startTime 整个尺的起始时间 / 时间区间的开始时间
 * @return {number}
 */
const timeToPoiX = (time, M = 1, startTime) => {
  const _ = Number(M) * interval
  return Math.abs(time - startTime) * 10 * 12 + _
}
const changePoi = (eventId) => {
  getApiEventDetails(eventId)
}
const changeText = (info) => {
  nowPoiText.value = info.id
  const nowPoiX = state.poiX
  const targetPoiX = timeToPoiX(info.yearInt, info.monthStr, tickStartTime)
  disMove(proxy.$refs.section, 'scrollLeft', nowPoiX, targetPoiX)
  getApiEventDetails(info.eventId)
}

watch(
  () => details.value,
  (data) => {
    console.log(data)
    if (data.summaryUrl) {
      openUrl(data.summaryUrl)
    } else {
      show.value = true
    }
  }
)

/**
 * @param moveDom 需移动的dom
 * @param direction 方向 scrollLeft scrollTop
 * @param start 开始的位置
 * @param end 目标位置
 * @param speed 速度
 */
const disMove = (moveDom, direction, start, end, speed = 10) => {
  let timer = null
  const _diff = Math.abs(start - end)
  let i = 0
  const fn = () => {
    if (start > end) {
      moveDom[direction] -= parseInt(_diff / speed)
    } else {
      moveDom[direction] += parseInt(_diff / speed)
    }
    ++i
    if (i === speed) {
      cancelAnimationFrame(timer)
      return
    }
    timer = requestAnimationFrame(fn)
  }
  timer = requestAnimationFrame(() => {
    fn()
  })
}

const getNowTime = (poiX, showMonth = true) => {
  const year = poiX / 10 / 12
  const nowMonth = parseInt((poiX / 10) % 12) + 1 // 每隔13刻到当前的年所在的1月
  const nowYear = parseInt(tickStartTime + year)
  const text = showMonth ? nowYear + '年' + nowMonth + '月' : nowYear
  return text
}

// 事件定位样式
const color = ['#C5E4D4', '#FFFDD7', '#F8C7DC', '#C7CDE7', '#F8C7DC', '#FAE0BF']

const listItemContentStyle = (info) => {
  if (!info._color) {
    info._color = color[Math.floor(Math.random() * 6)]
  }
  return {
    background: info._color
  }
}
const poiTextStyle = (info, startTIme, index) => {
  const diffTopH = 23
  const startTop = [0, diffTopH, diffTopH * 2, diffTopH * 3]
  return {
    left: timeToPoiX(info.yearInt, info.monthStr, tickStartTime) + 'px', // 3是将数据定位的原点偏移到线中心
    top: startTop[index % 4] + 'px'
  }
}

watch(
  () => state.poiX,
  (newValue) => {
    poiXTimeId.value && clearTimeout(poiXTimeId.value)
    const _newValue = newValue
    // 用户已经滑动结束并到指定位置
    if (_newValue === newValue) {
      poiXTimeId.value = setTimeout(() => {
        timeTOEvent(nowTime.value)
      }, 300)
    }
  }
)

const scrollFn = () => {
  state.poiX = proxy.$refs.section.scrollLeft
  state.timeId && clearTimeout(state.timeId)
  state.timeId = setTimeout(() => {
    // 放开手了 还在滑动 state.poiX 延迟后相等则是滑动完毕
    if (state.letGo && state.poiX === proxy.$refs.section.scrollLeft) {
      corrPoiX(true)
    }
  }, 200)
}
const corrPoiX = (status) => {
  const diff = state.poiX % state.tickInterval
  if (!diff) {
    proxy.$refs.section.scrollLeft = state.poiX
  } else if (diff <= 5) {
    // 向左偏移到指定刻度
    state.poiX -= diff
  } else {
    // 向又偏移到指定刻度
    state.poiX = state.poiX - diff + state.tickInterval
  }
  proxy.$refs.section.scrollLeft = state.poiX
  nowTime.value = state.poiX
  if (status) {
  }
  setTimeout(() => {}, 200)
  console.log('修正的poiX', state.poiX, status)
}
const touchendFn = () => {
  state.poiX = proxy.$refs.section.scrollLeft
  state.letGo = true
  corrPoiX()
}
const { moveDirection, touchstart, touchMove, touchend } = useTouch(
  0,
  touchendFn
)
const rulerLengthPx = computed(() => {
  return state.rulerLength + 'px'
})

const leftRulerLinePx = computed(() => {
  return document.body.offsetWidth / 2 + 'px' // 刻度尺安全边距
})
const placeholderWPx = computed(() => {
  return placeholderW + 'px'
})

const allLenPx = computed(() => {
  // 整个尺子的长度
  return document.body.offsetWidth + state.rulerLength + 'px'
})
/* ----------------------------------------------事件函数--------------------------------------------------*/
const isIos = computed(() => {
  return /ios|iphone|ipad|ipod/.test(navigator.userAgent.toLowerCase())
})
watch(
  () => moveDirection.value,
  (newValue) => {
    if (!isIos.value) {
      return
    }
    // ios 才设置不能同时左右滑动
    if (newValue === 1 || newValue === 2) {
      touchAction.value = 'pan-y'
    } else if (newValue === 3 || newValue === 4) {
      touchAction.value = 'pan-x'
    } else {
      touchAction.value = 'auto'
    }
  }
)
// 生成刻度
const generateTicks = () => {
  const numTicks = Math.floor(state.rulerLength / state.tickInterval) // 刻度尺上的刻度数量
  state.ticks = Array.from(
    { length: numTicks + 1 },
    (_, index) => index * state.tickInterval
  ) // 生成刻度值数组
  proxy.$nextTick(() => {
    state.poiX = proxy.$refs.section.scrollLeft
  })
}

/* ----------------------------------------------生命周期--------------------------------------------------*/
onMounted(() => {
  getList()
  generateTicks()
  proxy.$nextTick(async () => {
    const { height } = await rch.getStatusBarHeight()
    topH.value = height
  })
})
</script>
<style scoped lang="scss">
.chronological-table {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  user-select: none;
  position: relative;
  header {
    width: 100%;
    height: 290px;
    background: #e3f4ff;
  }
  .pointer {
    width: 0;
    height: calc(100% - 290px - 54px); // 54 rulers 高度
    box-sizing: border-box;
    border-left: 2px dashed red;
    position: fixed;
    left: 50%;
    bottom: 0;
    transform: translate(-50%, 0);
    z-index: 99;
    .poi-x {
      padding: 2px 12px;
      position: absolute;
      white-space: nowrap;
      left: -50%;
      transform: translateX(-50%);
      top: -60px;
      font-size: 15px;
      font-family: PingFangSC-Regular, PingFang SC;
      font-weight: 400;
      color: #333333;
      line-height: 21px;
      background: #ffffff;
      border-radius: 4px;
      border: 1px solid #df0010;
      backdrop-filter: blur(10px);
    }
    &:after {
      position: absolute;
      content: ' ';
      left: -11px;
      top: -5px;
      border-left: 10px solid transparent;
      border-right: 10px solid transparent;
      border-bottom: 20px solid red;
    }
  }
  section {
    width: 100%;
    height: calc(100% - 290px);
    overflow: auto;
    overflow-scrolling: touch;
    overscroll-behavior-x: none;
    overscroll-behavior-y: none;
    touch-action: v-bind(touchAction);
    .list {
      position: relative;
      z-index: 8;
      &-item {
        display: flex;
        width: v-bind(allLenPx);
        height: v-bind(blockH);
        border-bottom: 1px solid #fff;
        &-menu {
          width: 70px;
          position: sticky;
          font-size: 14px;
          font-family: PingFangSC-Regular, PingFang SC;
          font-weight: 400;
          color: #333333;
          line-height: 20px;
          top: 0;
          left: 0;
          will-change: transform;
          z-index: 11;
          border-bottom: 1px solid #cccccc;
          label {
            width: 100%;
            height: 100%;
            user-select: none;
            background: white;
            text-align: center;
            display: flex;
            align-items: center;
            justify-content: center;
          }
        }
        &-content {
          flex: 1;
          display: flex;
          .placeholder {
            width: v-bind(placeholderWPx);
            height: 100%;
            //background: rgba(218, 165, 32, 0.68);
          }
          .year-section {
            width: v-bind(rulerLengthPx);
            position: relative;
            user-select: none;
            height: 100%;
            .mask-text {
              font-size: 11px;
              font-family: PingFangSC-Regular, PingFang SC;
              font-weight: 400;
              color: rgba(0, 0, 0, 0.5);
              line-height: 16px;
              height: 100%;
              display: flex;
              align-items: center;
              justify-content: center;
            }
            .poi-text {
              position: absolute;
              z-index: 1;
              padding: 2px 4px;
              background: #ffffff;
              border-radius: 2px;
              transform: translate(10px, 10px); // 修正定位到刻度 一刻间隔是10
              left: 100px;
              top: 0;
              font-size: 12px;
              font-family: PingFangSC-Regular, PingFang SC;
              font-weight: 400;
              color: #333333;
              line-height: 17px;
              white-space: nowrap;
              &:nth-child(2n + 2) {
                top: 21px;
              }
              &:nth-child(2n + 3) {
                top: 63px;
              }
              &:after {
                position: absolute;
                content: '';
                width: 8px;
                height: 8px;
                border-radius: 50%;
                background: #fff;
                border: 2.5px solid rgba(52, 219, 201, 1);
                left: -12px;
                top: 30%;
                box-shadow: 0px 0px 2px 2px rgba(255, 255, 255, 0.5);
              }
            }
            .now-poi-text {
              z-index: 7;
            }
          }
        }
        .menu-relative {
          //position: relative;
        }
        .menu-fixed {
          //position: sticky;
          //left: 0;
        }
      }
    }
    .rulers {
      display: -webkit-box;
      -webkit-box-align: end;
      width: v-bind(allLenPx);
      position: sticky;
      position: -webkit-sticky;
      padding-top: 42px;
      top: 0;
      z-index: 10;
      background: white;

      .left-ruler-line,
      .right-ruler-line {
        width: v-bind(leftRulerLinePx);
        height: 2px;
        background-color: rgba(52, 219, 201, 1);
      }
      .ruler {
        display: flex;
        align-items: center;
        flex-direction: column;
      }

      .ruler-line {
        width: v-bind(rulerLengthPx);
        height: 2px;
        background-color: rgba(52, 219, 201, 1);
      }

      .ruler-ticks {
        display: flex;
        justify-content: space-between;
        width: v-bind(rulerLengthPx);
      }

      .ruler-tick {
        display: inline-block;
        width: 1px;
        height: 5px;
        margin-top: 4px;
        background-color: rgba(52, 219, 201, 1);
        &-big {
          margin-top: 0px;
          width: 2px;
          height: 10px;
          position: relative;
          span {
            font-size: 13px;
            font-family: PingFangSC-Regular, PingFang SC;
            font-weight: 400;
            color: #666666;
            line-height: 18px;
            position: absolute;
            left: -50%;
            top: -50%;
            transform: translate(-50%, -100%);
          }
        }
      }
    }
  }
}
.sheet-content {
  header {
    display: flex;
    padding: 12px;
    img {
      width: 100px;
      height: 128px;
      border-radius: 6px;
      object-fit: cover;
      margin-right: 12px;
    }
    .right {
      &-title {
        font-size: 18px;
        font-family: PingFangSC-Medium, PingFang SC;
        font-weight: bold;
        color: #333333;
        line-height: 25px;
      }
      &-dsc {
        margin-top: 4px;
        font-size: 12px;
        font-family: PingFangSC-Medium, PingFang SC;
        font-weight: 500;
        color: #999999;
        line-height: 20px;
      }
    }
  }
  .texts {
    .text-item {
      border: 0;
      font-size: 14px;
      font-family: PingFangSC-Light, PingFang SC;
      font-weight: 300;
      color: #333333;
      line-height: 20px;
      padding-top: 0;
      :deep(.dsc) {
        > p:nth-child(1) {
          margin-top: 0;
        }
      }
    }
  }
}
</style>