kodadot/nft-gallery

View on GitHub
components/chart/PriceChart.vue

Summary

Maintainability
Test Coverage
<template>
  <div
    class="common-price-chart"
    data-testid="gallery-item-chart"
  >
    <span class="chart-y-description text-xs"> Price ({{ chainSymbol }}) </span>
    <NeoDropdown
      class="py-0 time-range-dropdown"
      :mobile-modal="false"
    >
      <template #trigger="{ active }">
        <NeoButton
          :label="selectedTimeRange.label"
          class="time-range-button"
          no-shadow
          :active="active"
        />
      </template>

      <NeoDropdownItem
        v-for="range in timeRangeList"
        :key="range.value"
        class="flex justify-center items-center"
        :active="selectedTimeRange.value === range.value"
        :value="selectedTimeRange"
        @click="setTimeRange({ value: range.value, label: range.label })"
      >
        {{ range.label }}
      </NeoDropdownItem>
    </NeoDropdown>

    <NeoDropdown
      :mobile-modal="false"
      class="chart-setting-icon min-width-fit-content"
      position="bottom-left"
    >
      <template #trigger="{ active }">
        <NeoButton
          no-shadow
          variant="icon"
        >
          <NeoIcon
            icon="gear"
            pack="fass"
            size="large"
            :variant="!active ? 'k-grey' : undefined"
          />
        </NeoButton>
      </template>

      <NeoDropdownItem class="hover:bg-transparent px-0 py-0">
        <div class="w-full flex justify-between items-center">
          <NeoCheckbox
            v-model="vHideOutliers"
            class="m-0 whitespace-nowrap"
            root-class="flex-auto px-4 py-3"
          >
            {{ $t('activity.hideOutliers') }}
          </NeoCheckbox>
        </div>
      </NeoDropdownItem>
      <NeoDropdownItem class="hover:bg-transparent px-0 py-0">
        <div class="w-full flex justify-between items-center">
          <NeoCheckbox
            v-model="vApplySmoothing"
            class="m-0 whitespace-nowrap"
            root-class="flex-auto px-4 py-3"
          >
            {{ $t('activity.applySmoothing') }}
          </NeoCheckbox>
        </div>
      </NeoDropdownItem>
    </NeoDropdown>

    <div
      :class="{ content: !chartHeight }"
      :style="heightStyle"
    >
      <Line
        ref="chart"
        :data="chartData"
        :options="chartOptions"
        :plugins="chartPlugins"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import {
  NeoButton,
  NeoCheckbox,
  NeoDropdown,
  NeoDropdownItem,
  NeoIcon,
} from '@kodadot1/brick'
import { useEventListener, useVModel } from '@vueuse/core'
import type {
  ChartData,
  ChartDataset,
  ChartOptions,
  Point } from 'chart.js'
import {
  Chart as ChartJS,
  Legend,
  LineElement,
  LinearScale,
  PointElement,
  TimeScale,
  Title,
  Tooltip,
} from 'chart.js'
import 'chartjs-adapter-date-fns'
import zoomPlugin from 'chartjs-plugin-zoom'
import { format } from 'date-fns'
import { Line } from 'vue-chartjs'
import { getChartDataByTimeRange } from '@/utils/chart'

ChartJS.register(
  zoomPlugin,
  LinearScale,
  TimeScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
)

const props = defineProps<{
  priceChartData?: [Date, number][][]
  chartHeight?: string
  hideOutliers: boolean
  applySmoothing: boolean
}>()
const emit = defineEmits(['update:hideOutliers', 'update:applySmoothing'])

const { $i18n } = useNuxtApp()
const { chainSymbol } = useChain()
const { isDarkMode } = useTheme()

const timeRangeList = [
  {
    value: 0,
    label: $i18n.t('priceChart.all'),
  },
  {
    value: 14,
    label: $i18n.t('topCollections.timeFrames.week'),
  },
  {
    value: 30,
    label: $i18n.t('topCollections.timeFrames.month'),
  },
  {
    value: 90,
    label: $i18n.t('topCollections.timeFrames.quarter'),
  },
]

const selectedTimeRange = ref(timeRangeList[0])

const setTimeRange = (value: { value: number, label: string }) => {
  selectedTimeRange.value = value
}

const vHideOutliers = useVModel(props, 'hideOutliers', emit)
const vApplySmoothing = useVModel(props, 'applySmoothing', emit)

const heightStyle = computed(() =>
  props.chartHeight ? `height: ${props.chartHeight}` : '',
)

const chart = ref<{ chart: InstanceType<typeof ChartJS> } | null>(null)

const onWindowResize = () => {
  chart.value?.chart.resize()
}

useEventListener(window, 'resize', onWindowResize)

const lineColor = computed(() => (isDarkMode.value ? '#fff' : '#181717'))
const gridColor = computed(() => (isDarkMode.value ? '#6b6b6b' : '#cccccc'))

const displayChartData = computed(() => {
  if (props.priceChartData) {
    const timeRangeValue = selectedTimeRange.value.value
    return [
      getChartDataByTimeRange(props.priceChartData[0], timeRangeValue),
      getChartDataByTimeRange(props.priceChartData[1], timeRangeValue),
    ]
  }
  else {
    return []
  }
})

const commonStyle = computed(() => {
  return {
    tension: 0.2,
    pointRadius: 6,
    pointHoverRadius: 6,
    pointHoverBackgroundColor: isDarkMode.value ? '#181717' : 'white',
    borderJoinStyle: 'round' as const,
    radius: 0,
    pointStyle: 'rect',
    borderWidth: 1,
    lineTension: 0,
  }
})

const chartData = computed<ChartData<'line', Point[], unknown>>(() => {
  if (displayChartData.value?.length) {
    const salePoints = getChartPoints(displayChartData.value[0])
    const listPoints = getChartPoints(displayChartData.value[1])
    return {
      datasets: [
        {
          label: 'Sale',
          data: salePoints,
          borderColor: '#FF7AC3',
          pointBackgroundColor: '#FF7AC3',
          pointBorderColor: '#FF7AC3',
          ...commonStyle.value,
        } as ChartDataset<'line', Point[]>,
        {
          label: 'List',
          data: listPoints,
          borderColor: '#6188E7',
          pointBackgroundColor: '#6188E7',
          pointBorderColor: '#6188E7',
          ...commonStyle.value,
        } as ChartDataset<'line', Point[]>,
      ],
    }
  }
  return {
    datasets: [],
  }
})

const chartOptions = computed<ChartOptions<'line'>>(() => ({
  maintainAspectRatio: false,
  responsive: true,
  responsiveAnimationDuration: 0,
  transitions: {
    resize: {
      animation: {
        duration: 0,
      },
    },
  },
  plugins: {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    customCanvasBackgroundColor: {
      color: isDarkMode.value ? '#181717' : 'white',
    },
    legend: {
      labels: {
        usePointStyle: true,
      },
    },
    tooltip: {
      xAlign: 'center',
      yAlign: 'top',
      callbacks: {
        label: function (context) {
          return `Price: ${context.parsed.y} ${chainSymbol.value}`
        },
        title: function (context) {
          return format(context[0].parsed.x, 'yyyy-MM-dd HH:mm')
        },
      },
    },
    zoom: {
      limits: {
        x: { min: 0, minRange: 0 },
        y: { min: 0, minRange: 0 },
      },
      pan: {
        enabled: false,
      },
      zoom: {
        wheel: {
          enabled: false,
        },
        pinch: {
          enabled: false,
        },
        mode: 'xy',
        onZoomComplete({ chart }) {
          chart.update('none')
        },
      },
    },
  },
  scales: {
    x: {
      type: 'time',
      time: {
        displayFormats: {
          hour: 'HH:mm',
          minute: 'HH:mm',
        },
        unit: 'hour',
      },
      grid: {
        drawOnChartArea: false,
        borderColor: lineColor.value,
        color: gridColor.value,
      },
      ticks: {
        callback: (value) => {
          return format(Number(value), 'MMM dd')
        },
        major: {
          enabled: true,
        },
        maxRotation: 0,
        minRotation: 0,
        color: lineColor.value,
      },
    },
    y: {
      ticks: {
        callback: (value) => {
          return `${Number(value).toFixed(2)}  `
        },
        stepSize: 3,
        color: lineColor.value,
      },
      grid: {
        drawTicks: false,
        color: gridColor.value,
        borderColor: lineColor.value,
      },
    },
  },
}))

const chartPlugins = computed(() => [
  {
    id: 'customCanvasBackgroundColor',
    beforeDraw: (chart, args, options) => {
      const { ctx } = chart
      ctx.save()
      ctx.globalCompositeOperation = 'destination-over'
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      ctx.fillStyle = options.color || '#FFFFFF'
      ctx.fillRect(0, 0, chart.width, chart.height)
      ctx.restore()
    },
  },
])
</script>

<style scoped>
.content {
  height: 15rem;
}

.chart-setting-icon {
  position: absolute;
  right: 8px;
  top: -5px;
}

.min-width-fit-content {
  :deep(.o-drop__menu) {
    min-width: fit-content !important;
  }
}
</style>