Skip to content

Commit 6ee9eb7

Browse files
committed
Range editor
1 parent 858946b commit 6ee9eb7

19 files changed

Lines changed: 975 additions & 74 deletions

File tree

src/components/curve/CurveEditor.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil'
8989
9090
import type { CurveInterpolation, CurvePoint } from './types'
9191
92-
import { histogramToPath } from './curveUtils'
92+
import { histogramToPath } from '@/utils/histogramUtil'
9393
9494
const {
9595
curveColor = 'white',

src/components/curve/curveUtils.test.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import type { CurvePoint } from './types'
55
import {
66
createLinearInterpolator,
77
createMonotoneInterpolator,
8-
curvesToLUT,
9-
histogramToPath
8+
curvesToLUT
109
} from './curveUtils'
1110

1211
describe('createMonotoneInterpolator', () => {
@@ -164,37 +163,3 @@ describe('curvesToLUT', () => {
164163
}
165164
})
166165
})
167-
168-
describe('histogramToPath', () => {
169-
it('returns empty string for empty histogram', () => {
170-
expect(histogramToPath(new Uint32Array(0))).toBe('')
171-
})
172-
173-
it('returns empty string when all bins are zero', () => {
174-
expect(histogramToPath(new Uint32Array(256))).toBe('')
175-
})
176-
177-
it('returns a closed SVG path for valid histogram', () => {
178-
const histogram = new Uint32Array(256)
179-
for (let i = 0; i < 256; i++) histogram[i] = i + 1
180-
const path = histogramToPath(histogram)
181-
expect(path).toMatch(/^M0,1/)
182-
expect(path).toMatch(/L1,1 Z$/)
183-
})
184-
185-
it('normalizes using 99.5th percentile to suppress outliers', () => {
186-
const histogram = new Uint32Array(256)
187-
for (let i = 0; i < 256; i++) histogram[i] = 100
188-
histogram[255] = 100000
189-
const path = histogramToPath(histogram)
190-
// Most bins should map to y=0 (1 - 100/100 = 0) since
191-
// the 99.5th percentile is 100, not the outlier 100000
192-
const yValues = path
193-
.split(/[ML]/)
194-
.filter(Boolean)
195-
.map((s) => parseFloat(s.split(',')[1]))
196-
.filter((y) => !isNaN(y))
197-
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
198-
expect(nearZero.length).toBeGreaterThan(200)
199-
})
200-
})

src/components/curve/curveUtils.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -149,34 +149,6 @@ export function createMonotoneInterpolator(
149149
}
150150
}
151151

152-
/**
153-
* Convert a histogram (arbitrary number of bins) into an SVG path string.
154-
* Applies square-root scaling and normalizes using the 99.5th percentile
155-
* to avoid outlier spikes.
156-
*/
157-
export function histogramToPath(histogram: Uint32Array): string {
158-
const len = histogram.length
159-
if (len === 0) return ''
160-
161-
const sqrtValues = new Float32Array(len)
162-
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
163-
164-
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
165-
const max = sorted[Math.floor((len - 1) * 0.995)]
166-
if (max === 0) return ''
167-
168-
const invMax = 1 / max
169-
const lastIdx = len - 1
170-
const parts: string[] = ['M0,1']
171-
for (let i = 0; i < len; i++) {
172-
const x = lastIdx === 0 ? 0.5 : i / lastIdx
173-
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
174-
parts.push(`L${x},${y}`)
175-
}
176-
parts.push('L1,1 Z')
177-
return parts.join(' ')
178-
}
179-
180152
export function curvesToLUT(
181153
points: CurvePoint[],
182154
interpolation: CurveInterpolation = 'monotone_cubic'
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { mount } from '@vue/test-utils'
2+
import { describe, expect, it } from 'vitest'
3+
import { createI18n } from 'vue-i18n'
4+
5+
import RangeEditor from './RangeEditor.vue'
6+
7+
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
8+
9+
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
10+
return mount(RangeEditor, {
11+
props,
12+
global: { plugins: [i18n] }
13+
})
14+
}
15+
16+
describe('RangeEditor', () => {
17+
it('renders with min and max handles', () => {
18+
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
19+
20+
expect(wrapper.find('svg').exists()).toBe(true)
21+
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
22+
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
23+
})
24+
25+
it('highlights selected range in plain mode', () => {
26+
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
27+
28+
const highlight = wrapper.find('[data-testid="range-highlight"]')
29+
expect(highlight.attributes('x')).toBe('0.2')
30+
expect(highlight.attributes('width')).toBe('0.6000000000000001')
31+
})
32+
33+
it('dims area outside the range in histogram mode', () => {
34+
const histogram = new Uint32Array(256)
35+
for (let i = 0; i < 256; i++)
36+
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
37+
38+
const wrapper = mountEditor({
39+
modelValue: { min: 0.2, max: 0.8 },
40+
display: 'histogram',
41+
histogram
42+
})
43+
44+
const left = wrapper.find('[data-testid="range-dim-left"]')
45+
const right = wrapper.find('[data-testid="range-dim-right"]')
46+
expect(left.attributes('width')).toBe('0.2')
47+
expect(right.attributes('x')).toBe('0.8')
48+
})
49+
50+
it('hides midpoint handle by default', () => {
51+
const wrapper = mountEditor({
52+
modelValue: { min: 0, max: 1, midpoint: 0.5 }
53+
})
54+
55+
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
56+
})
57+
58+
it('shows midpoint handle when showMidpoint is true', () => {
59+
const wrapper = mountEditor({
60+
modelValue: { min: 0, max: 1, midpoint: 0.5 },
61+
showMidpoint: true
62+
})
63+
64+
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
65+
})
66+
67+
it('renders gradient background when display is gradient', () => {
68+
const wrapper = mountEditor({
69+
modelValue: { min: 0, max: 1 },
70+
display: 'gradient',
71+
gradientStops: [
72+
{ offset: 0, color: [0, 0, 0] as const },
73+
{ offset: 1, color: [255, 255, 255] as const }
74+
]
75+
})
76+
77+
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
78+
expect(wrapper.find('linearGradient').exists()).toBe(true)
79+
})
80+
81+
it('renders histogram path when display is histogram with data', () => {
82+
const histogram = new Uint32Array(256)
83+
for (let i = 0; i < 256; i++)
84+
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
85+
86+
const wrapper = mountEditor({
87+
modelValue: { min: 0, max: 1 },
88+
display: 'histogram',
89+
histogram
90+
})
91+
92+
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
93+
})
94+
95+
it('renders inputs for min and max', () => {
96+
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
97+
98+
const inputs = wrapper.findAll('input')
99+
expect(inputs).toHaveLength(2)
100+
})
101+
102+
it('renders midpoint input when showMidpoint is true', () => {
103+
const wrapper = mountEditor({
104+
modelValue: { min: 0, max: 1, midpoint: 0.5 },
105+
showMidpoint: true
106+
})
107+
108+
const inputs = wrapper.findAll('input')
109+
expect(inputs).toHaveLength(3)
110+
})
111+
112+
it('normalizes handle positions with custom value range', () => {
113+
const wrapper = mountEditor({
114+
modelValue: { min: 64, max: 192 },
115+
valueMin: 0,
116+
valueMax: 255
117+
})
118+
119+
const minHandle = wrapper.find('[data-testid="handle-min"]')
120+
const maxHandle = wrapper.find('[data-testid="handle-max"]')
121+
122+
expect(minHandle.attributes('style')).toContain('left: 25.0')
123+
expect(maxHandle.attributes('style')).toContain('left: 75.')
124+
})
125+
})

0 commit comments

Comments
 (0)