<script setup lang="ts">
import { axisLeft } from 'd3-axis'
import { scaleLinear } from 'd3-scale'
import { select } from 'd3-selection'
import get from 'lodash/get'
import identity from 'lodash/identity'
import {
  ComponentPublicInstance,
  computed,
  getCurrentInstance,
  ref,
  nextTick,
  toRef,
  watch
} from 'vue'
import { getChartProps, useChart } from '@/composables/useChart'
import { useStackedChart } from '@/composables/useStackedChart'

defineOptions({
  name: 'StackedColumnChart'
})

export interface StackedColumnChartProps {
  /**
   * Field of each object containing data (for each group)
   */
  keys?: string[]
  /**
   * Group name to display in the legend
   */
  groups?: string[]
  /**
   * Colors of each bar group
   */
  barColors?: string[]
  /**
   * Max with of each bar.
   */
  barMaxWidth?: string
  /**
   * Hide bars that have no values.
   */
  hideEmptyValues?: boolean
  /**
   * Hide the legend.
   */
  hideLegend?: boolean
  /**
   * Enforce the height of the chart (regardless of the width or number of row)
   */
  fixedHeight?: number | null
  /**
   * Function to apply to format x-axis ticks
   */
  xAxisTickFormat?: ((v: any) => string) | string
  /**
   * Function to apply to format y-axis ticks (bars value). It can be a
   * function returning the formatted value or a d3's formatter string.
   */
  yAxisTickFormat?: ((v: any) => string) | string
  /**
   * Padding on y-axis ticks
   */
  yAxisTickPadding?: number
  /**
   * Field containing the label for each column
   */
  labelField?: string
  /**
   * Sort groups by one or several keys.
   */
  sortBy?: string | string[] | null
  /**
   * Column height is relative to each group's total
   */
  relative?: boolean
  /**
   * A list of highlighted groups
   */
  highlights?: string[]
  /**
   * Delay to apply when set the first highlight
   */
  highlightDelay?: number
  /**
   * A list of entire column to highlight
   */
  columnHighlights?: string[]
  /**
   * Delay to apply when restoring highlights to initial state
   */
  restoreHighlightDelay?: number
  /**
   * Deactivate direct labeling on bars
   */
  noDirectLabeling?: boolean
  /**
   * Set max value instead of extracting it from the data.
   */
  maxValue?: number | null
  /**
   * Function to define tooltip content.
   */
  tooltipDisplay?: (params: { value?: any, formattedKey: string, formattedValue: string, key?: string }) => string
  /**
   * Hide bar tooltips
   */
  noTooltips?: boolean
  data?: string | Record<string, unknown>[] | null
  dataUrlType?: 'json' | 'csv' | 'tsv'
  chartHeightRatio?: number
  socialMode?: boolean
  socialModeRatio?: number
}

const props = withDefaults(defineProps<StackedColumnChartProps>(), {
  keys: () => [],
  groups: () => [],
  barColors: () => [],
  barMaxWidth: '100%',
  hideEmptyValues: false,
  hideLegend: false,
  fixedHeight: null,
  xAxisTickFormat: () => identity,
  yAxisTickFormat: () => identity,
  yAxisTickPadding: 10,
  labelField: 'date',
  sortBy: null,
  relative: false,
  highlights: () => [],
  highlightDelay: 400,
  columnHighlights: () => [],
  restoreHighlightDelay: 50,
  noDirectLabeling: false,
  maxValue: null,
  tooltipDisplay: ({ formattedKey, formattedValue }: { formattedKey: string, formattedValue: string }) => {
    return `<h6 class="mb-0">${formattedKey}</h6><div>${formattedValue}</div>`
  },
  noTooltips: false,
  data: null,
  dataUrlType: 'json',
  chartHeightRatio: undefined,
  socialMode: false,
  socialModeRatio: 5 / 4
})

const emit = defineEmits<{
  loaded: [data: Record<string, unknown>[] | null]
  resized: []
}>()

const width = ref(0)
const height = ref(0)
const leftAxisRedrawCount = ref(0)
const leftAxisHeight = ref(0)
const highlightedKeys = ref(props.highlights)
const highlightTimeout = ref<ReturnType<typeof setTimeout> | undefined>(undefined)
const isLoaded = ref(false)
const el = ref<ComponentPublicInstance<HTMLElement> | null>(null)

const {
  elementsMaxBBox,
  baseHeightRatio,
  loadedData,
  mounted,
  d3Formatter
} = useChart<Record<string, unknown>[]>(el, getChartProps(props), { emit }, isLoaded, setSizes)

const {
  sortedData,
  discoveredKeys,
  colorScale,
  totalRowValue,
  maxStackValue,
  groupName
} = useStackedChart({
  loadedData,
  isLoaded,
  sortBy: toRef(() => props.sortBy),
  keys: toRef(() => props.keys),
  labelField: toRef(() => props.labelField),
  groups: toRef(() => props.groups),
  barColors: toRef(() => props.barColors)
})

const hasHighlights = computed(() => {
  return !!highlightedKeys.value.length
})

const hasColumnHighlights = computed(() => {
  return !!props.columnHighlights.length
})

const leftScale = computed(() => {
  return scaleLinear()
    .domain([0, maxRowValue.value])
    .range([leftAxisHeight.value, 0])
})

const leftAxis = computed(() => {
  return axisLeft(leftScale.value)
    // d3-axis' domain type is NumberValue (number | { valueOf(): number }),
    // not the plain number d3Formatter expects.
    .tickFormat(d => String(d3Formatter(Number(d), props.yAxisTickFormat)))
    .tickSize(Math.max(0, width.value - leftAxisLabelsWidth.value))
    .tickPadding(props.yAxisTickPadding)
})

const leftAxisLabelsWidth = computed((): number => {
  // Track redraw counter to re-measure after axis text is created in the DOM
  void leftAxisRedrawCount.value
  const selector = '.stacked-column-chart__left-axis__canvas .tick text'
  const defaultWidth = 0
  return (
    (elementsMaxBBox({ selector, defaultWidth }).width ?? defaultWidth)
    + props.yAxisTickPadding
  )
})

const leftAxisCanvas = computed(() => {
  return select(el.value)
    .select('.stacked-column-chart__left-axis__canvas')
})

const paddedStyle = computed(() => {
  return {
    marginLeft: props.noDirectLabeling
      ? `${leftAxisLabelsWidth.value + props.yAxisTickPadding}px`
      : 0
  }
})

const barTooltipDelay = computed(() => {
  return hasHighlights.value ? 0 : props.highlightDelay
})

const maxRowValue = computed(() => {
  return props.maxValue ?? (maxStackValue.value as number)
})

function setSizes() {
  if (!el.value) {
    return
  }
  width.value = el.value.offsetWidth
  height.value
    = props.fixedHeight !== null
      ? props.fixedHeight
      : width.value * baseHeightRatio.value
}

function highlight(key: string) {
  highlightedKeys.value = [key]
}

function restoreHighlights() {
  clearTimeout(highlightTimeout.value)
  const delay = props.restoreHighlightDelay
  // Delay the restoration so it can be cancelled by a new highlight
  highlightTimeout.value = setTimeout(
    () => (highlightedKeys.value = props.highlights),
    delay
  )
}

function delayHighlight(key: string) {
  clearTimeout(highlightTimeout.value)
  // Reduce the delay to zero if there is already a highlighted key
  const isDelayed = !hasHighlights.value
  const delay = isDelayed ? props.highlightDelay : 0
  highlightTimeout.value = setTimeout(() => highlight(key), delay)
}

function isHighlighted(key: string) {
  return highlightedKeys.value.indexOf(key) > -1
}

function isColumnHighlighted(i: string | number) {
  const column = get(sortedData.value, [i, props.labelField], null)
  return (
    props.columnHighlights.includes(column) && !highlightedKeys.value.length
  )
}

function barStyle(i: string | number, key: string) {
  const value = sortedData.value[i as number][key]
  let totalWidth = props.relative ? totalRowValue(i) : maxRowValue.value
  if (!totalWidth) {
    console.error('totalWidth as divider cannot be ' + totalWidth)
    totalWidth = 100
  }
  const height = `${100 * (value / totalWidth)}%`
  const backgroundColor = colorScale.value(key)
  const maxWidth = props.barMaxWidth
  return { maxWidth, height, backgroundColor }
}

function barTitle(i: string | number, key: string) {
  const value = sortedData.value[i as number][key]
  const formattedValue = String(d3Formatter(value, props.yAxisTickFormat))
  const formattedKey = groupName(key)
  return props.tooltipDisplay({ value, formattedValue, key, formattedKey })
}

function barUniqueId(i: string | number, key: string) {
  const { uid } = getCurrentInstance()!
  return `bar-${uid}-${i}-${key}`
}

interface StackItem {
  key: string
  barEdge: number
  barHeight: number
  rowEdge: number
  valueHeight: number
  overflow: boolean
  pushed: boolean
}

function stackBarAndValue(i: string | number): StackItem[] {
  if (!sortedData.value) {
    return []
  }
  // Collect sizes first
  const stack: StackItem[] = discoveredKeys.value.map((key: string) => {
    const { bar, row, value } = queryBarAndValue(i as number, key)
    if (!bar || !row || !value) {
      throw new Error('Empty values for bar, row or value')
    }
    const barEdge = bar.getBoundingClientRect().top + bar.offsetHeight
    const barHeight = bar.offsetHeight
    const rowEdge = row.getBoundingClientRect().top + row.offsetHeight
    const valueHeight = value.offsetHeight
    return {
      key,
      barEdge,
      barHeight,
      rowEdge,
      valueHeight,
      overflow: false,
      pushed: false
    }
  })
  // Infer value's display
  return stack.map((desc: StackItem, index: number) => {
    desc.overflow = desc.valueHeight >= desc.barHeight
    if (index > 0) {
      const prevDesc = stack[index - 1]
      const bothValuesHeight = desc.valueHeight + prevDesc.valueHeight
      desc.overflow
        = desc.overflow
          || (prevDesc.overflow && desc.barHeight < bothValuesHeight)
    }
    desc.pushed
      = desc.barEdge + desc.valueHeight > desc.rowEdge && desc.overflow
    return desc
  })
}

function queryBarAndValue(i: number, key: string) {
  const root = el.value as unknown as HTMLElement
  if (!mounted.value || !root) {
    return {}
  }
  const rowSelector = '.stacked-column-chart__groups__item'
  const row = root.querySelectorAll(rowSelector)[i] as HTMLElement
  const barSelector = `.stacked-column-chart__groups__item__bars__item--${key}`
  const bar = row.querySelector(barSelector) as HTMLElement
  const valueSelector = '.stacked-column-chart__groups__item__bars__item__value'
  const value = bar.querySelector(valueSelector) as HTMLElement
  return { bar, row, value }
}

function isHidden(i: string | number, key: string) {
  return props.hideEmptyValues && !sortedData.value[i as number][key]
}

interface LabelState {
  overflow: boolean
  pushed: boolean
  hidden: boolean
}

const labelStates = ref<Record<string, LabelState>>({})

function labelStateKey(i: number | string, key: string) {
  return `${i}-${key}`
}

function computeLabelStates() {
  const root = el.value as unknown as HTMLElement
  if (!root || !mounted.value || !sortedData.value?.length) return

  const states: Record<string, LabelState> = {}

  for (let i = 0; i < sortedData.value.length; i++) {
    try {
      const stack = stackBarAndValue(i)
      for (const item of stack) {
        states[labelStateKey(i, item.key)] = {
          overflow: item.overflow,
          pushed: item.pushed,
          hidden: false
        }
      }
      // A value is hidden when both it and the next key overflow
      for (let j = 0; j < stack.length; j++) {
        const nextItem = stack[j + 1]
        if (nextItem && stack[j].overflow && nextItem.overflow) {
          states[labelStateKey(i, stack[j].key)].hidden = true
        }
      }
    }
    catch {
      // If measurement fails for a row, skip it
    }
  }

  labelStates.value = states
}

function hasValueOverflow(i: string | number, key: string) {
  return labelStates.value[labelStateKey(i, key)]?.overflow ?? false
}

function hasValuePushed(i: string | number, key: string) {
  return labelStates.value[labelStateKey(i, key)]?.pushed ?? false
}

function hasValueHidden(i: string | number, key: string) {
  return labelStates.value[labelStateKey(i, key)]?.hidden ?? false
}

function formatXDatum(d: string) {
  return d3Formatter(d, props.xAxisTickFormat)
}

function formatYDatum(d: string) {
  return d3Formatter(d, props.yAxisTickFormat)
}

watch(
  () => props.highlights,
  (newHighlights: string[]) => {
    highlightedKeys.value = newHighlights
  }
)

watch(sortedData, async () => {
  await nextTick()
  const root = el.value as unknown as HTMLElement
  if (!root) return
  // This must be set after the column have been rendered
  const element = root.querySelector('.stacked-column-chart__groups__item__bars') as HTMLElement
  // Update the left axis only if the bars exists
  if (element) {
    leftAxisHeight.value = element.offsetHeight
    // First draw creates tick text in the DOM
    leftAxisCanvas.value.call(leftAxis.value as any)
    // Invalidate leftAxisLabelsWidth so it re-measures the new tick text
    leftAxisRedrawCount.value++
    await nextTick()
    // Second draw uses the correct tickSize and margins
    leftAxisCanvas.value.call(leftAxis.value as any)
  }
  // Compute label overflow/pushed/hidden states after DOM layout
  computeLabelStates()
})
</script>

<template>
  <div
    ref="el"
    :style="{ height: `${height}px` }"
    class="stacked-column-chart d-flex flex-column"
    :class="{
      'stacked-column-chart--social-mode': socialMode,
      'stacked-column-chart--has-highlights':
        hasHighlights || hasColumnHighlights,
      'stacked-column-chart--no-direct-labeling': noDirectLabeling
    }"
  >
    <ul
      v-if="!hideLegend"
      class="stacked-column-chart__legend list-inline"
    >
      <li
        v-for="key in discoveredKeys"
        :key="key"
        class="stacked-column-chart__legend__item list-inline-item d-inline-flex"
        :class="{
          'stacked-column-chart__legend__item--highlighted': isHighlighted(key)
        }"
        @mouseover="delayHighlight(key)"
        @mouseleave="restoreHighlights()"
      >
        <span
          class="stacked-column-chart__legend__item__box"
          :style="{ 'background-color': colorScale(key) }"
        />
        <span class="stacked-column-chart__legend__item__label">{{ groupName(key) }}</span>
      </li>
    </ul>
    <div class="stacked-column-chart__chart d-flex flex-column flex-grow-1 position-relative">
      <svg
        v-show="noDirectLabeling"
        :width="width + 'px'"
        :height="(leftAxisHeight + 20) + 'px'"
        class="stacked-column-chart__left-axis"
      >
        <g
          class="stacked-column-chart__left-axis__canvas"
          :transform="`translate(${width}, 0)`"
        />
      </svg>
      <div
        class="stacked-column-chart__groups d-flex flex-grow-1 overflow-hidden"
        :style="paddedStyle"
      >
        <div
          v-for="(datum, i) in sortedData"
          :key="i"
          class="stacked-column-chart__groups__item flex-grow-1 d-flex flex-column text-center"
        >
          <div
            class="stacked-column-chart__groups__item__bars flex-grow-1 d-flex flex-column-reverse px-1 justify-content-start align-items-center"
          >
            <div
              v-for="(key, j) in discoveredKeys"
              :id="barUniqueId(i, key)"
              :key="j"
              class="stacked-column-chart__groups__item__bars__item"
              :class="{
                [`stacked-column-chart__groups__item__bars__item--${key}`]: true,
                [`stacked-column-chart__groups__item__bars__item--${j}n`]: true,
                'stacked-column-chart__groups__item__bars__item--hidden': isHidden(i, key),
                'stacked-column-chart__groups__item__bars__item--highlighted': isHighlighted(key) || isColumnHighlighted(i),
                'stacked-column-chart__groups__item__bars__item--value-overflow': hasValueOverflow(i, key),
                'stacked-column-chart__groups__item__bars__item--value-pushed': hasValuePushed(i, key),
                'stacked-column-chart__groups__item__bars__item--value-hidden': hasValueHidden(i, key)
              }"
              :style="barStyle(i, key)"
              @mouseover="delayHighlight(key)"
              @mouseleave="restoreHighlights()"
            >
              <div
                v-show="!noDirectLabeling"
                class="stacked-column-chart__groups__item__bars__item__value"
              >
                {{ formatYDatum(datum[key]) }}
              </div>
              <teleport to="body">
                <b-tooltip
                  v-if="!noTooltips"
                  :delay="barTooltipDelay"
                  :target="barUniqueId(i, key)"
                  placement="top"
                >
                  <span v-html="barTitle(i, key)" />
                </b-tooltip>
              </teleport>
            </div>
          </div>
        </div>
      </div>
      <div
        class="stacked-column-chart__labels d-flex"
        :style="paddedStyle"
      >
        <div
          v-for="(datum, i) in sortedData"
          :key="i"
          class="stacked-column-chart__groups__item__label flex-grow-1 small py-2 text-center"
        >
          {{ formatXDatum(datum[labelField]) }}
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss">

.stacked-column-chart {
  $muted-group-opacity: 0.2;
  $muted-group-filter: grayscale(30%) brightness(10%);
  $muted-group-transition:
    opacity 0.3s,
    filter 0.3s;
  $colors: $primary, $info, $warning;
  $quantile: 3;

  @each $start-color in $colors {
    $i: index($colors, $start-color) - 1;
    $end-color: mix($start-color, text-contrast($start-color), 20%);

    @for $j from ($quantile * $i) through ($quantile * $i + $quantile - 1) {
      $amount: ($j % $quantile) * math.div(100%, $quantile);
      --group-color-#{$j}n: #{mix($end-color, $start-color, $amount)};
    }
  }

  &__legend {
    &__item {
      display: inline-flex;
      flex-direction: row;
      align-items: center;
      padding-right: $spacer * 0.5;

      @for $i from 0 through ($quantile * length($colors)) {
        &:nth-child(#{$i + 1}n) &__box {
          background-color: var(--group-color-#{$i}n);
        }
      }

      .stacked-column-chart--has-highlights &:not(&--highlighted) {
        opacity: $muted-group-opacity;
        filter: $muted-group-filter;
      }

      &__box {
        height: 1em;
        width: 1em;
        border-radius: 0.5em;
        display: inline-block;
        margin-right: $spacer * 0.5;
      }
    }
  }

  &__left-axis {
    position: absolute;
    top: 0;
    left: 0;

    path {
      display: none;
    }

    .tick {
      line {
        stroke: $border-color;
      }

      text {
        font-family: $font-family-base;
        font-size: $font-size-sm;
        fill: currentColor;
      }
    }
  }

  &__groups {
    &__item {
      &__bars {
        &__item {
          width: 100%;
          position: relative;
          min-height: 1px;

          @for $i from 0 through ($quantile * length($colors)) {
            &--#{$i}n {
              background: var(--group-color-#{$i}n);
            }
          }

          &__value {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            text-align: center;
            white-space: nowrap;
            color: #fff;
          }

          .stacked-column-chart--has-highlights &:not(&--highlighted) {
            opacity: $muted-group-opacity;
            filter: $muted-group-filter;
          }

          .stacked-column-chart--has-highlights &:not(&--highlighted) &__value {
            visibility: hidden;
          }

          .stacked-column-chart:not(.stacked-column-chart--has-highlights)
            &--value-hidden
            &__value,
          .stacked-column-chart:not(.stacked-column-chart--has-highlights)
            &--value-pushed
            &__value {
            visibility: hidden;
          }

          &--hidden {
            display: none;
          }

          &--value-overflow &__value {
            color: $body-color;
            transform: translateY(-100%);
          }

          &--value-pushed {
            direction: ltr;
          }

          &--value-pushed &__value {
            color: $body-color;
            transform: translateY(100%);
          }
        }
      }
    }
  }
}
</style>
