vuetify icon indicating copy to clipboard operation
vuetify copied to clipboard

feat(VPie): create new component

Open J-Sek opened this issue 9 months ago • 3 comments

Description

closes #20391

Includes

  • VPie - main component
  • VPieSegment - based on SVG
  • VPieTooltip - floating VListItem with color indicator, title and value
  • useTransition - simplified version with API similar to the equivalent from VueUse

TODO:

  • props / API
    • [x] series » items
    • [x] width » inner-cut
    • [x] legend as complex prop { position, textFormat }
    • [x] animation as complex prop { duration, easing }
    • [x] palette prop
  • misc
    • [x] new docs page with examples
    • [x] props descriptions
    • ~~tests ?~~ later
  • internals
    • [x] VTooltip with VAvatar and VListItem as default
    • (later) [a11y] ~~SVG patterns built into the framework - similar to decals from ECharts~~

Notes:

  • SVG rendering artifacts on semi-transparent slice (visible on the screenshot below) might depend on device and the browser (Chromium). Just checked and it looks fine on both Zen and Firefox
JS bundle size impact
File Before After
vuetify-labs.esm.js 991.82 kB 1.01 MB (up by 40~45 kB)

Markup:

Basic usage
<template>
  <v-app>
    <v-container>
      <div class="d-flex ga-12 align-start justify-center">
        <v-pie
          :items="items"
          :palette="palette"
          item-key="id"
          title="Basic Pie"
          animation
          legend
          tooltip
        />
        <v-pie
          :inner-cut="85"
          :items="items"
          :palette="palette"
          item-key="id"
          title="Funky donut"
          animation
          tooltip
        />
        <v-pie
          :hover-scale=".2"
          :inner-cut="70"
          :items="items"
          :palette="palette"
          item-key="id"
          title="Moooouuu..."
          animation
          hide-slice
          tooltip
        >
          <template #center>
            <v-icon color="#4e9963" icon="mdi-cow" size="90" />
          </template>
        </v-pie>
        <v-pie
          :gauge-cut="140"
          :hover-scale=".2"
          :inner-cut="50"
          :items="items"
          :palette="palette"
          item-key="id"
          title="Gauge"
          animation
          hide-slice
          tooltip
        />
      </div>
    </v-container>
  </v-app>
</template>

<script setup lang="ts">
  const palette = ['#2b6d40', '#4e9963', '#72c789', '#97f7b0']
  const items = [
    { id: 1, title: 'Series A', value: 45 },
    { id: 2, title: 'Series B', value: 30 },
    { id: 3, title: 'Series C', value: 15 },
    { id: 4, title: 'Series D', value: 10 },
  ]
</script>
Kitchen sink
<template>
  <v-app>
    <v-btn
      class="ma-2"
      icon="mdi-theme-light-dark"
      location="top right"
      position="absolute"
      @click="$vuetify.theme.toggle()"
    />
    <v-container>

      <div class="d-flex my-6 justify-center">
        <v-card class="pa-6" elevation="6" rounded="xl">
          <v-card-title class="d-flex align-center justify-space-between">
            <div class="text-truncate">Expense Analysis</div>
            <v-select
              :items="['Transactions', 'Other']"
              density="compact"
              max-width="200"
              model-value="Transactions"
              variant="solo-filled"
              flat
              hide-details
              single-line
            />
          </v-card-title>
          <v-pie
            :animation="{ duration: 250 }"
            :items="items1"
            :legend="{ position: 'right' }"
            :tooltip="{ subtitleFormat: '[value]%' }"
            class="pa-3 mt-3"
            gap="2"
            inner-cut="75"
            item-key="id"
            rounded="2"
            size="300"
            hide-slice
          >
            <template #center>
              <div class="text-center">
                <div class="text-h3">130</div>
                <div class="opacity-70 mt-1 mb-n1">Total</div>
              </div>
            </template>
            <template #legend="{ items, toggle, isActive }">
              <v-list class="py-0 bg-transparent" density="compact" width="300">
                <v-list-item
                  v-for="item in items"
                  :key="item.key"
                  :class="['my-1', { 'opacity-40': !isActive(item) }]"
                  :title="item.title"
                  rounded="lg"
                  link
                  @click="toggle(item)"
                >
                  <template #prepend>
                    <v-avatar :color="item.color" :size="16" />
                  </template>
                  <template #append>
                    <div class="font-weight-bold">{{ item.value }}%</div>
                  </template>
                </v-list-item>
              </v-list>
            </template>
          </v-pie>
        </v-card>
      </div>

      <div class="d-flex ga-12 justify-center">
        <v-pie
          :items="items2a"
          hover-scale=".2"
          item-key="id"
          size="160"
          title="Basic Pie"
        />
        <v-pie
          :animation="{ duration: 250 }"
          :items="items2b"
          inner-cut="80"
          item-key="id"
          size="220"
          title="Funky donut"
          tooltips
        />
        <div class="d-flex flex-column align-center">
          <v-pie
            :animation="{ duration: 250 }"
            :bg-color="$vuetify.theme.current.dark ? 'surface' : '#fff'"
            :items="items2c"
            class="mb-n16 position-relative"
            gap="3"
            hover-scale="0"
            inner-cut="75"
            item-key="id"
            rounded="5"
            size="160"
            style="z-index: 2"
            hide-slice
          >
            <template #center>
              <div class="text-center">
                <div class="text-h4 mb-n2">59%</div>
                <small>completed</small>
              </div>
            </template>
          </v-pie>
          <v-card class="pt-16 px-2 pb-3 text-center" color="#F0A202" rounded="xl" width="220">
            <div class="mt-6 mb-2">Milestone 2.0.0</div>
            <v-divider class="mx-3 mb-1" />
            <small class="text-overline my-0 opacity-60">on track</small>
          </v-card>
        </div>
      </div>
      <div class="d-flex mt-6 justify-center">
        <v-sheet class="pa-6" elevation="6" rounded="xl">
          <v-pie
            :animation="{ duration: animationDuration }"
            :density="['compact', 'comfortable', 'default'][density]"
            :hover-scale="hoverScale"
            :items="items3"
            :legend="{ position: 'right' }"
            :tooltip="{
              subtitleFormat: (s) => `${formatNumber(s.value)} respondents (${(100 * s.value / 50212).toFixed(1)}%)`,
              transition: { name: 'scroll-y-reverse-transition', duration: 150 }
            }"
            inner-cut="75"
            item-key="id"
            size="300"
            title="JavaScript alternatives"
            hide-slice
          >
            <template #center="{ total }">
              <div class="text-center">
                <v-icon class="opacity-60" icon="mdi-vote-outline" size="44" />
                <div class="mt-2">{{ formatNumber(total) }} votes</div>
              </div>
            </template>
            <template #legend-text="{ item }">
              <div class="d-flex ga-6">
                <div>{{ item.title }}</div>
                <div class="ml-auto font-weight-bold">
                  {{ formatNumber(item.value) }}
                </div>
              </div>
            </template>
          </v-pie>
          <v-slider
            v-model="hoverScale"
            :ticks="{ 0: 'smaller', .25: 'larger' }"
            class="mb-n6 mt-6"
            label="hover scale"
            max=".25"
            min="0"
            show-ticks="always"
          />
          <v-slider
            v-model="animationDuration"
            :ticks="{ 0: 'faster', 1000: 'slower' }"
            class="mb-n6 mt-6"
            label="animation speed"
            max="1000"
            min="0"
            show-ticks="always"
            step="100"
          />
          <v-slider
            v-model="density"
            :ticks="{ 0: 'compact', 1: 'comfortable', 2: 'default' }"
            class="mb-n3 mt-6"
            label="legend density"
            max="2"
            min="0"
            show-ticks="always"
            step="1"
          />
        </v-sheet>
      </div>

      <div class="h-0">
        <svg height="0" version="1.1" width="0" xmlns="http://www.w3.org/2000/svg">
          <!-- source: https://pattern.monster -->
          <defs>
            <pattern
              id="pattern-0"
              height="20"
              patternTransform="rotate(145) scale(.2)"
              patternUnits="userSpaceOnUse"
              width="20"
            >
              <path d="M0 10h20zm0 20h20zm0 20h20zm0 20h20z" fill="none" stroke="rgb(var(--v-theme-surface))" stroke-width="3" />
            </pattern>
            <pattern
              id="pattern-1"
              height="20"
              patternTransform="scale(.5)"
              patternUnits="userSpaceOnUse"
              width="40"
            >
              <path
                d="M40 0 20-10V0l20 10zm0 10L20 0v10l20 10zm0 10L20 10v10l20 10zM0 20l20-10v10L0 30zm0-10L20 0v10L0 20zM0 0l20-10V0L0 10z"
                fill="none"
                stroke="rgb(var(--v-theme-surface))"
              />
            </pattern>
            <pattern
              id="pattern-2"
              height="8"
              patternTransform="scale(.5)"
              patternUnits="userSpaceOnUse"
              width="70"
            >
              <path
                d="M-.02 22c8.373 0 11.938-4.695 16.32-9.662C20.785 7.258 25.728 2 35 2s14.215 5.258 18.7 10.338C58.082 17.305 61.647 22 70.02 22M-.02 14.002C8.353 14 11.918 9.306 16.3 4.339 20.785-.742 25.728-6 35-6S49.215-.742 53.7 4.339c4.382 4.967 7.947 9.661 16.32 9.664M70 6.004c-8.373-.001-11.918-4.698-16.3-9.665C49.215-8.742 44.272-14 35-14S20.785-8.742 16.3-3.661C11.918 1.306 8.353 6-.02 6.002"
                fill="none"
                stroke="rgb(var(--v-theme-surface))"
              />
            </pattern>
            <pattern
              id="pattern-3"
              height="10"
              patternTransform="scale(.5)"
              patternUnits="userSpaceOnUse"
              width="10"
            >
              <path
                d="M5 0v10ZM0 5h10Z"
                fill="none"
                stroke="rgb(var(--v-theme-surface))"
              />
            </pattern>
            <pattern
              id="dark-stripes"
              height="20"
              patternTransform="rotate(145) scale(.2)"
              patternUnits="userSpaceOnUse"
              width="20"
            >
              <path d="M0 10h20zm0 20h20zm0 20h20zm0 20h20z" fill="none" stroke="#222" stroke-width="3" />
            </pattern>
          </defs>
        </svg>
      </div>
      <div class="d-flex mt-6 justify-center">
        <v-pie
          :items="items4"
          :legend="{ position: 'left' }"
          :tooltip="{ subtitleFormat: '[value]%' }"
          hover-scale=".1"
          inner-cut="50"
          item-key="id"
          size="200"
          hide-slice
          segment
        >
          <template #center>
            <div class="text-center">
              <v-icon class="opacity-60" icon="mdi-leaf" size="44" />
            </div>
          </template>
        </v-pie>
      </div>
    </v-container>
  </v-app>
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  const numberFormatter = new Intl.NumberFormat('en', { useGrouping: true })
  function formatNumber (v: number) {
    return numberFormatter.format(v)
  }

  const items1 = [
    { id: 1, title: 'House & Bills', value: 40, color: 'rgba(var(--v-theme-on-surface), .2)', pattern: 'url(#pattern-0)' },
    { id: 2, title: 'Transportation', value: 25, color: 'rgba(255, 151, 215, .4)' },
    { id: 3, title: 'Entertainment', value: 20, color: 'rgba(255, 151, 215, .6)' },
    { id: 4, title: 'Food', value: 10, color: 'rgba(255, 151, 215, .8)' },
    { id: 5, title: 'Other', value: 5, color: 'rgba(255, 151, 215, 1)' },
  ]

  const items2a = [
    { id: 1, title: 'Series A', value: 45, color: '#2b6d40' },
    { id: 2, title: 'Series B', value: 30, color: '#4e9963' },
    { id: 3, title: 'Series C', value: 15, color: '#72c789' },
    { id: 4, title: 'Series D', value: 10, color: '#97f7b0' },
  ]

  const items2b = [
    { id: 2, title: 'Google', value: 75, color: '#0080bb' },
    { id: 1, title: 'Bing', value: 20, color: '#58508d' },
    { id: 3, title: 'DuckDuckGo', value: 17, color: '#bc5090' },
    { id: 4, title: 'Brave', value: 15, color: '#ff6361' },
    { id: 5, title: 'Kagi', value: 5, color: '#ffa600' },
  ]

  const items2c = [
    { id: 1, title: 'Current progress', value: 59, color: '#eb6542', pattern: 'url(#dark-stripes)' },
    { id: 5, title: 'rest', value: 41, color: '#8884' },
  ]

  const items3 = [
    { id: 1, title: 'TypeScript', value: 17674, color: '#13475c' },
    { id: 2, title: 'Elm', value: 3550, color: '#006c71' },
    { id: 3, title: 'CoffeeScript', value: 1251, color: '#008e59' },
    { id: 4, title: 'Civet', value: 531, color: '#ffa600' },
    { id: 5, title: 'N/A', value: 9821, color: '#6662' },
  ]
  const animationDuration = ref(250)
  const density = ref(2)
  const hoverScale = ref(0.1)

  const items4 = [
    { id: 1, title: 'Walnut', value: 57, color: '#607322', pattern: 'url(#pattern-1)' },
    { id: 2, title: 'Oak', value: 31, color: '#c19a00', pattern: 'url(#pattern-2)' },
    { id: 3, title: 'Pine', value: 12, color: '#ffa600', pattern: 'url(#pattern-3)' },
  ]
</script>

image

image

J-Sek avatar Mar 28 '25 11:03 J-Sek

I'd love this to be merged. What's the main thing that's holding it back?

Revadike avatar May 13 '25 07:05 Revadike

I'd love this to be merged. What's the main thing that's holding it back?

Some cleanup, naming adjustments, bundle impact measurement. I will try to push it over the weekend.

J-Sek avatar May 13 '25 08:05 J-Sek

I'd love this to be merged. What's the main thing that's holding it back?

Some cleanup, naming adjustments, bundle impact measurement. I will try to push it over the weekend.

Had time to work on it yet? 🤞

Revadike avatar May 18 '25 13:05 Revadike

Rebased, added reveal, replaced circle with another path to simplify and make it more cohesive. There is just a tiny fix in locationStrategies.ts, so maybe it could land in master? or be merged right away to be included in 3.9.0?

J-Sek avatar Jun 27 '25 07:06 J-Sek

Rebased, added reveal, replaced circle with another path to simplify and make it more cohesive. There is just a tiny fix in locationStrategies.ts, so maybe it could land in master? or be merged right away to be included in 3.9.0?

If you move this to Labs it can go out in any patch.

johnleider avatar Jul 07 '25 21:07 johnleider

Great news! I have just included a final fix. Centered content with gauge-cut was slightly misaligned. Now... I'd hate to interrupt Kael fixing dev to pass through CI/CD. It can wait another day or two.

J-Sek avatar Jul 07 '25 22:07 J-Sek

@J-Sek I finally was able to try it out via vuetify labs. Thank you so much for the PR. I have some remarks though:

  • It would be nice to specify a color directly to each item. Maybe add the color property to :items items?
  • You should also support color variables (e.g. success, error, info)

Revadike avatar Sep 14 '25 02:09 Revadike

  • items support color, palette is not required
  • colors from app theme are mostly supported, just the VChip chokes on OKLCH transformation

J-Sek avatar Sep 14 '25 02:09 J-Sek

It's better to add "Chart" as a prefix to the component name:

VChatPie.

Because this component introduce a new category: charts.

rodrigoslayertech avatar Nov 05 '25 18:11 rodrigoslayertech