Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/curve/CurveEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil'

import type { CurveInterpolation, CurvePoint } from './types'

import { histogramToPath } from './curveUtils'
import { histogramToPath } from '@/utils/histogramUtil'

const {
curveColor = 'white',
Expand Down
37 changes: 1 addition & 36 deletions src/components/curve/curveUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
curvesToLUT
} from './curveUtils'

describe('createMonotoneInterpolator', () => {
Expand Down Expand Up @@ -164,37 +163,3 @@ describe('curvesToLUT', () => {
}
})
})

describe('histogramToPath', () => {
it('returns empty string for empty histogram', () => {
expect(histogramToPath(new Uint32Array(0))).toBe('')
})

it('returns empty string when all bins are zero', () => {
expect(histogramToPath(new Uint32Array(256))).toBe('')
})

it('returns a closed SVG path for valid histogram', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = i + 1
const path = histogramToPath(histogram)
expect(path).toMatch(/^M0,1/)
expect(path).toMatch(/L1,1 Z$/)
})

it('normalizes using 99.5th percentile to suppress outliers', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++) histogram[i] = 100
histogram[255] = 100000
const path = histogramToPath(histogram)
// Most bins should map to y=0 (1 - 100/100 = 0) since
// the 99.5th percentile is 100, not the outlier 100000
const yValues = path
.split(/[ML]/)
.filter(Boolean)
.map((s) => parseFloat(s.split(',')[1]))
.filter((y) => !isNaN(y))
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
expect(nearZero.length).toBeGreaterThan(200)
})
})
28 changes: 0 additions & 28 deletions src/components/curve/curveUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,34 +149,6 @@ export function createMonotoneInterpolator(
}
}

/**
* Convert a histogram (arbitrary number of bins) into an SVG path string.
* Applies square-root scaling and normalizes using the 99.5th percentile
* to avoid outlier spikes.
*/
export function histogramToPath(histogram: Uint32Array): string {
const len = histogram.length
if (len === 0) return ''

const sqrtValues = new Float32Array(len)
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])

const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
const max = sorted[Math.floor((len - 1) * 0.995)]
if (max === 0) return ''

const invMax = 1 / max
const lastIdx = len - 1
const parts: string[] = ['M0,1']
for (let i = 0; i < len; i++) {
const x = lastIdx === 0 ? 0.5 : i / lastIdx
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
parts.push(`L${x},${y}`)
}
parts.push('L1,1 Z')
return parts.join(' ')
}

export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'
Expand Down
131 changes: 131 additions & 0 deletions src/components/range/RangeEditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'

import RangeEditor from './RangeEditor.vue'

const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })

function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
props,
global: { plugins: [i18n] }
})
}

describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })

expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
})

it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })

const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
expect(
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
).toBeCloseTo(0.6, 6)
})
Comment thread
jtydhr88 marked this conversation as resolved.

it('dims area outside the range in histogram mode', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))

const wrapper = mountEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})

const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
})

it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})

expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
})

it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})

expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
})

it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
})

expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
})

it('renders histogram path when display is histogram with data', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))

const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})

expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
})

it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })

const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(2)
})

it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})

const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(3)
})

it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})

const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')

expect(
Number.parseFloat((minHandle.element as HTMLElement).style.left)
).toBeCloseTo(25, 0)
expect(
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
).toBeCloseTo(75, 0)
})
})
Loading
Loading