Skip to content

feat(VHeatmap): add new component#22535

Open
J-Sek wants to merge 20 commits intodevfrom
feat/vheatmap
Open

feat(VHeatmap): add new component#22535
J-Sek wants to merge 20 commits intodevfrom
feat/vheatmap

Conversation

@J-Sek
Copy link
Copy Markdown
Contributor

@J-Sek J-Sek commented Jan 20, 2026

Description

  • new VHeatmap component to visualize data in the grid container
  • supports arbitrary groups
  • easy to use for typical weeks/months
  • hover effect (scale down)

Markup:

<template>
  <v-app>
    <v-container max-width="1050">
      <v-row class="mb-6">
        <v-col>
          <v-slider
            v-model="cellSize"
            :max="50"
            :min="10"
            :step="1"
            label="Cell size"
            hide-details
          />
        </v-col>
        <v-col>
          <v-slider
            v-model="gap"
            :max="16"
            :min="0"
            :step="1"
            label="Gap"
            hide-details
          />
        </v-col>
        <v-col>
          <v-slider
            v-model="roundedIndex"
            :max="5"
            :min="0"
            :step="1"
            :ticks="Object.fromEntries(roundedOptions.map((v, i) => [i, v]))"
            label="Rounded"
            show-ticks="always"
            hide-details
          />
        </v-col>
      </v-row>

      <v-row class="mb-6 justify-center">
        <v-col cols="auto">
          <v-card>
            <v-card-title class="px-8 pt-6 pb-3">Average Wind Strength</v-card-title>
            <v-card-text class="px-6 pt-3 pb-8">
              <v-heatmap
                :cell-size="cellSize"
                :columns="regions"
                :gap="gap"
                :item-props="(item: any) => ({ title: `${item.row} ${item.column}: ${item.value} km/h` })"
                :items="windItems"
                :legend="{ cellSize: 20 }"
                :rounded="rounded"
                :rows="years"
                :thresholds="windThresholds"
                item-column="region"
                item-row="year"
                item-value="speed"
                hover
              />
            </v-card-text>
          </v-card>
        </v-col>
        <v-col cols="auto">
          <v-card>
            <v-card-title class="px-8 pt-6 pb-3">Correlation Matrix</v-card-title>
            <v-card-text class="px-6 pt-3 pb-8">
              <v-heatmap
                :cell-size="[cellSize, cellSize]"
                :gap="gap"
                :items="matrixItems"
                :legend="{ cellSize: 16 }"
                :rounded="rounded"
                :thresholds="matrixThresholds"
                item-column="column"
                item-row="row"
                item-value="value"
              >
                <template #cell="{ item }">
                  <span class="v-heatmap-cell__text">{{ item.value }}</span>
                </template>
              </v-heatmap>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>

      <v-row class="mb-6 justify-center">
        <v-col v-for="metric in metrics" :key="metric.label" cols="auto">
          <v-card>
            <v-card-title class="px-8 pt-6">{{ metric.label }}</v-card-title>
            <v-card-text class="px-6 pt-3 pb-3">
              <v-heatmap
                :cell-size="[9, 50]"
                :gap="3"
                :items="metric.items"
                :rounded="rounded"
                :thresholds="[{ min: 1, color: metric.color }]"
                item-column="col"
                item-row="row"
                item-value="value"
                type="grid"
                hide-column-headers
                hide-legend
                hide-row-headers
              />
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>

      <v-card>
        <v-card-title class="px-8 pt-6 pb-3">Documents uploaded</v-card-title>
        <v-card-text class="px-6 pt-3 pb-8">
          <v-heatmap
            :cell-size="cellSize"
            :gap="gap"
            :group-by="(v) => v.date.substring(0, 7)"
            :item-row="(v) => new Date(v.date).getDay()"
            :items="docItems"
            :rounded="rounded"
            :rows="[0, 1, 2, 3, 4, 5, 6]"
            :thresholds="docThresholds"
            style="max-width: 100%"
            hover
            legend
          >
            <template #row-header="{ items }">
              {{ adapter.format(items[0].raw.date, 'weekdayShort') }}
            </template>
            <template #group-header="{ items }">
              <div class="text-center">
                {{ adapter.format(items[0].raw.date, 'month') }}
              </div>
            </template>
            <template #cell="{ item }">
              <v-tooltip
                v-if="item"
                :close-delay="0"
                :open-delay="400"
                activator="parent"
                location="bottom"
                open-on-hover
              >
                <span>
                  {{ adapter.format(item.raw.date, 'shortDate') }}:
                  <span :class="{ 'font-weight-bold': item.value > 0 }">{{ item.value || 'No' }}</span>
                  document{{ item.value === 1 ? '' : 's' }}
                </span>
              </v-tooltip>
            </template>
          </v-heatmap>
        </v-card-text>
      </v-card>

      <v-row class="my-6">
        <v-col cols="auto">
          <v-card>
            <v-card-title class="px-8 pt-6 pb-3">Activity</v-card-title>
            <v-card-text class="px-6 pt-3 pb-8">
              <v-heatmap
                v-bind="{ items, columns, thresholds }"
                :cell-size="cellSize"
                :gap="gap"
                :rounded="rounded"
                hide-legend
                hide-row-headers
              >
                <template #column-header="{ column }">
                  {{ weekLetters[column] }}
                </template>
              </v-heatmap>
            </v-card-text>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
    <v-btn
      class="ma-2"
      icon="mdi-theme-light-dark"
      location="top right"
      position="absolute"
      @click="$vuetify.theme.cycle()"
    />
  </v-app>
</template>

<script setup lang="ts">
  import { useDate, useTheme } from '@/composables'
  import { ref, toRef } from 'vue'

  const { change, global } = useTheme()
  change('dark')

  const adapter = useDate()

  // ---- Controls ----

  const cellSize = ref(28)
  const gap = ref(6)
  const roundedOptions = ['0', 'sm', 'md', 'lg', 'xl', 'pill'] as const
  const roundedIndex = ref(2)
  const rounded = toRef(() => roundedOptions[roundedIndex.value])

  // ---- Grid: Wind strength across Canadian regions ----

  const regions = ['BC', 'AB', 'SK', 'MB', 'ON', 'QC', 'NB', 'NS', 'PE', 'NL', 'YT', 'NT', 'NU']
  const years = Array.from({ length: 8 }, (_, i) => String(2018 + i))

  const windItems = regions.flatMap(region =>
    years.map(year => ({
      year,
      region,
      speed: Math.round(5 + Math.random() * 40),
    }))
  )

  const windThresholds = [
    { min: 1, color: '#00876c' },
    { min: 10, color: '#88af77' },
    { min: 20, color: '#e3d49c' },
    { min: 30, color: '#df915c' },
    { min: 40, color: '#d43d51' },
  ]

  // ---- Grid: Correlation matrix ----

  const matrixRows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
  const matrixCols = ['G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8']

  const matrixItems = matrixRows.flatMap(row =>
    matrixCols.map(column => ({
      row,
      column,
      value: Math.round(Math.random() * 10) / 10,
    }))
  )

  const matrixThresholds = [
    { min: 0.1, color: '#440154' },
    { min: 0.2, color: '#443983' },
    { min: 0.3, color: '#31688e' },
    { min: 0.4, color: '#21918c' },
    { min: 0.6, color: '#35b779' },
    { min: 0.8, color: '#90d743' },
    { min: 0.9, color: '#fde725' },
  ]

  const metrics = [
    { label: 'CPU Usage', color: '#1b5e20', items: ((v: number) => Array.from({ length: 20 }, (_, col) => ({ col, value: col <= v ? 1 : 0 })))(4) },
    { label: 'Memory', color: '#1b5e20', items: ((v: number) => Array.from({ length: 20 }, (_, col) => ({ col, value: col <= v ? 1 : 0 })))(15) },
    { label: 'Disk I/O', color: '#1b5e20', items: ((v: number) => Array.from({ length: 20 }, (_, col) => ({ col, value: col <= v ? 1 : 0 })))(7) },
  ]

  // ---- Calendar: Documents uploaded ----

  const currentYear = new Date().getFullYear()
  const startMonth = 3 * Math.floor(new Date().getMonth() / 3 - 1)
  const monthCount = 6

  const docThresholds = toRef(() => global.current.value.dark
    ? [
      { min: 1, color: '#12334C' },
      { min: 4, color: '#1D5783' },
      { min: 8, color: '#2978B3' },
      { min: 12, color: '#3BABFF' },
    ] : [
      { min: 1, color: '#ACD3F0' },
      { min: 4, color: '#83BDE9' },
      { min: 8, color: '#59A7E1' },
      { min: 12, color: '#2674AE' },
    ])

  const docItems: { date: string, value: number }[] = []
  for (let i = 0; i < monthCount; i++) {
    const month = startMonth + i
    const daysCount = new Date(currentYear, month + 1, 0).getDate()
    for (let day = 0; day < daysCount; day++) {
      const date = new Date(currentYear, month, day + 1)
      const isWeekend = date.getDay() === 0 || date.getDay() === 6
      const isFuture = date > new Date()
      const value = isWeekend || isFuture ? 0 : Math.floor(Math.random() * 14.99)
      const m = String(date.getMonth() + 1).padStart(2, '0')
      const d = String(date.getDate()).padStart(2, '0')
      docItems.push({ date: `${currentYear}-${m}-${d}`, value })
    }
  }

  // ---- Usage demo ----

  const weekLetters = 'SMTWTFS'
  const columns = [1, 2, 3, 4, 5, 6, 0]

  const thresholds = [
    { min: 1, color: '#cfe0ff' },
    { min: 3, color: '#89a7ff' },
    { min: 6, color: '#4a76e8' },
    { min: 9, color: '#1e47c2' },
  ]

  const values = [
    null, 0, 0, 2, 4, 1, 0,
    1, 3, 4, 7, 2, 1, 0,
    0, 2, 5, 9, 6, 3, 1,
    0, 1, 3, 4, 2, 0, 0,
    2, 4, 3, 1,
  ]
  const items = values.flatMap((value, i) =>
    value !== null
      ? [{
        row: Math.floor(i / 7),
        column: (i + 1) % 7,
        value,
      }]
      : []
  )
</script>

@J-Sek J-Sek self-assigned this Jan 20, 2026
@J-Sek J-Sek added the C: New Component This issue would need a new component to be developed. label Jan 20, 2026
@J-Sek J-Sek changed the base branch from next to master March 8, 2026 00:54
@J-Sek J-Sek marked this pull request as ready for review April 13, 2026 01:29
J-Sek added 6 commits April 13, 2026 19:18
- refactored VHeatmap to support grid
- extracted useHeatmap composable
- extracted VHeatmapCell and VHeatmapLegend
- props for size, hover, rounding and legend visibility
@J-Sek J-Sek changed the base branch from master to dev April 13, 2026 17:21
@J-Sek J-Sek added this to the v4.1.0 milestone Apr 13, 2026
@J-Sek J-Sek requested a review from a team April 13, 2026 20:54
@johnleider
Copy link
Copy Markdown
Member

Cross-repo thought — purely to spark discussion, not a blocker for shipping

The part of useHeatmap doing the real work is the long → wide pivot: items[] + itemRow/itemColumn/groupBy{ rows, columns, groups, cells }. Threshold→color is bolted on at the end and is mostly independent.

Some features that same shape could power:

  • Calendar grids / contribution graphs
  • Scheduler / Gantt
  • Pivot tables / cross-tabs
  • Confusion matrices
  • Small-multiples chart layouts
  • Availability / booking grids

Feels like a createPivot candidate in @vuetify/v0 — sibling to createDataTable rather than a replacement, since DataTable is row-oriented with a fixed column schema and this derives columns from data. VHeatmap could consume it once it stabilizes.

Two bits worth preserving verbatim if it ever gets lifted:

  1. The dual column-resolution modes — explicit via prop/accessor vs inferred by row-key wrapping. The inferred mode is what makes calendar-style layouts trivial.
  2. The color-mix(in srgb, ...) linear interpolation — though that one probably belongs in @vuetify/paper next to useColor/useContrast.

Again — VHeatmap should ship as-is and iterate in labs. Just floating it while the design is fresh.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C: New Component This issue would need a new component to be developed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants