Skip to content

Commit b95636f

Browse files
committed
Range editor
1 parent 858946b commit b95636f

19 files changed

Lines changed: 984 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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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(
31+
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
32+
).toBeCloseTo(0.6, 6)
33+
})
34+
35+
it('dims area outside the range in histogram mode', () => {
36+
const histogram = new Uint32Array(256)
37+
for (let i = 0; i < 256; i++)
38+
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
39+
40+
const wrapper = mountEditor({
41+
modelValue: { min: 0.2, max: 0.8 },
42+
display: 'histogram',
43+
histogram
44+
})
45+
46+
const left = wrapper.find('[data-testid="range-dim-left"]')
47+
const right = wrapper.find('[data-testid="range-dim-right"]')
48+
expect(left.attributes('width')).toBe('0.2')
49+
expect(right.attributes('x')).toBe('0.8')
50+
})
51+
52+
it('hides midpoint handle by default', () => {
53+
const wrapper = mountEditor({
54+
modelValue: { min: 0, max: 1, midpoint: 0.5 }
55+
})
56+
57+
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
58+
})
59+
60+
it('shows midpoint handle when showMidpoint is true', () => {
61+
const wrapper = mountEditor({
62+
modelValue: { min: 0, max: 1, midpoint: 0.5 },
63+
showMidpoint: true
64+
})
65+
66+
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
67+
})
68+
69+
it('renders gradient background when display is gradient', () => {
70+
const wrapper = mountEditor({
71+
modelValue: { min: 0, max: 1 },
72+
display: 'gradient',
73+
gradientStops: [
74+
{ offset: 0, color: [0, 0, 0] as const },
75+
{ offset: 1, color: [255, 255, 255] as const }
76+
]
77+
})
78+
79+
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
80+
expect(wrapper.find('linearGradient').exists()).toBe(true)
81+
})
82+
83+
it('renders histogram path when display is histogram with data', () => {
84+
const histogram = new Uint32Array(256)
85+
for (let i = 0; i < 256; i++)
86+
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
87+
88+
const wrapper = mountEditor({
89+
modelValue: { min: 0, max: 1 },
90+
display: 'histogram',
91+
histogram
92+
})
93+
94+
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
95+
})
96+
97+
it('renders inputs for min and max', () => {
98+
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
99+
100+
const inputs = wrapper.findAll('input')
101+
expect(inputs).toHaveLength(2)
102+
})
103+
104+
it('renders midpoint input when showMidpoint is true', () => {
105+
const wrapper = mountEditor({
106+
modelValue: { min: 0, max: 1, midpoint: 0.5 },
107+
showMidpoint: true
108+
})
109+
110+
const inputs = wrapper.findAll('input')
111+
expect(inputs).toHaveLength(3)
112+
})
113+
114+
it('normalizes handle positions with custom value range', () => {
115+
const wrapper = mountEditor({
116+
modelValue: { min: 64, max: 192 },
117+
valueMin: 0,
118+
valueMax: 255
119+
})
120+
121+
const minHandle = wrapper.find('[data-testid="handle-min"]')
122+
const maxHandle = wrapper.find('[data-testid="handle-max"]')
123+
124+
expect(
125+
Number.parseFloat((minHandle.element as HTMLElement).style.left)
126+
).toBeCloseTo(25, 0)
127+
expect(
128+
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
129+
).toBeCloseTo(75, 0)
130+
})
131+
})

0 commit comments

Comments
 (0)