我正在尝试为 Svelte 编写基于 SVG 的垂直数字范围滑块组件。
我的问题是关于转换的,尤其是SVG getScreenCTM()矩阵。
该组件设计为简单且可通过 props 自定义,如下所示:
<!-- user markup file .svelte -->
<SVGVerticalSlider
title="100 units range"
value={50}
units=""
min={0}
max={100}
step={1}
major={10}
minor={2.5}
/>
它的工作原理是显示从 {min} 到 {max} 的范围,带有带有 {minor } 和 {major} 行的渐变轨道,并标记每条主要行,进度为 {step},并显示带有 {value + " + units} 的标记可拖动箭头。
SVG 中使用的坐标系是 {YSTEP} 从 {YMIN} 到 {YMAX},因此我使用线性插值函数来计算值:
// linear interpolation function
const lerp = (Xa, Ya, Xb, Yb, x) => Ya + (x - Xa) * (Yb - Ya) / (Xb - Xa)
箭头函数在 SVG 内部的组中绘制:
<g
class="slider"
transform={ 'translate(0,' + Y_VALUE + ')' }
on:mousedown={DND.start}
on:mousemove={DND.move}
on:mouseup={DND.end}
on:mouseleave={DND.end}>
<!-- path_point = simple fat arrow draw pointing on Y_VALUE at (0, 0) -->
<path d={path_points} style={ 'fill:' + bg + ';' }/>
<text x="10" y="3" class="fat-arrow">{ formatted_value } </text>
</g>
在 DND.move() 事件列表器中,如果单击位置,执行矩阵 CTM 操作,然后计算两个位置的差异,因此我可以计算 Y_COUNT = offset.y/YSTEP 将鼠标拖放到内联 SVG 上的步数。
所以反应式绑定是:
let formatted_value = 0
$: formatted_value = units ? value.toFixed(2) + ' ' + units : value
$: if (init) {
Y_VALUE = lerp (min, YMIN, max, YMAX, value)
init = false
} else {
// value += Y_COUNT * step ...
if (Y_COUNT > 0)
value += Math.floor (Y_COUNT) * step
else
value += Math.ceil(Y_COUNT) * step
// new value of the cursor in SVG coordinates:
Y_VALUE = lerp (min, YMIN, max, YMAX, value)
// range guards (max)
if (value >= max) {
value = max
Y_VALUE = YMAX
}
// range guards (min)
if (value <= min) {
value = min
Y_VALUE = YMIN
}
}
这里做了什么?
- 每次触发 move() 事件 ID 时记录鼠标位置
- 两个连续的位置不同给了我偏移量。
- 计算Y_COUNT = offset.y/YSTEP 为我们提供了移动箭头的步骤量
- 计算组件的新用户值,以Y_COUNT的整数部分计数(Math.floor 或 Math.ceil)
- 计算箭头位置的新Y_VALUE,并在 SVG 标记上绑定 IIT
问题:
我可以观察到鼠标指针位置和箭头的有效计算位置之间存在滞后,因此鼠标指针和箭头位置不会粘在一起!
我尝试过各种数字刻度
scale min max step
----------------------------------------
normalized 1 100 1
big_money 0 350000 2500
percentage 0 5 0.1
滞后效应并不明显依赖于规模分布和
如何解决?
组件的 HTML + JS + Svelte 代码(200+ 行代码)
<!-- this is an attempt to build a nice SVG vertical numeric range slider for Svelte -->
<script>
import { onMount } from 'svelte'
export let title = 'default-title'
export let min = 0
export let max = 100
export let step = 1
export let major = 10
export let minor = 5
export let value = 50
export let units = '' // whatever: €, %, ...
// markers list
let markers = []
let majors = []
let minors = []
/**
COordinate Systèm
user (component) level min max step value offset count
(tranform lerp)
internal 0..100 range YMIN YMAX YSTEP Y_VALUE Y_OFFSET Y_COUNT
SVG coordinates
(transfo-rm ScreenCTM)
client cords (mouse) client.step event clientY offset.y
*/
// linear interpolation function
const lerp = (Xa, Ya, Xb, Yb, x) => Ya + (x - Xa) * (Yb - Ya) / (Xb - Xa)
const XMIN = 0
const YMIN = 0
const XMAX = 50
const YMAX = 100
// initialisation phase flag
let init = true
// internal variable representing Y
let Y_VALUE = 0
// internam step
let YSTEP0 = lerp (min, YMIN, max, YMAX, step)
let YSTEP = 1.0 * (YMAX - YMIN) / ((max - min) / step)
// console.log (YSTEP)
// console.log (YSTEP0)
// internal step count
let Y_COUNT = 0
// user scale steps count
let y_count = 0
// Y_OFFSET inside SVG coords
let Y_OFFSET = 0
let bg = 'yellow'
let yaxis = {
x1: 0, y1: 0,
x2: 0, y2: 95
}
let formatted_value = 0
$: formatted_value = units ? value.toFixed(2) + ' ' + units : value
$: if (init) {
Y_VALUE = lerp (min, YMIN, max, YMAX, value)
init = false
} else {
let yamount = 0
// without Math.floor or Math.ceil, it simply would be:
// value += Y_COUNT * step
// but in fact we're doing step-by-step:
if (Y_COUNT > 0) {
// if (Y_COUNT < 1) Y_COUNT = 1
yamount = Math.ceil (Y_COUNT) * step
} else {
// if (Y_COUNT > -1) Y_COUNT = -1
yamount = Math.floor (Y_COUNT) * step
}
value += yamount
Y_VALUE = lerp (min, YMIN, max, YMAX, value)
if (value >= max) {
value = max
Y_VALUE = YMAX
}
if (value <= min) {
value = min
Y_VALUE = YMIN
}
}
// example path:
let path_points = 'M0,0 L10,-8 L50,-8 L50,8 L10,8 Z'
// Drag'n DRop is kept inside a closure (IIFE)
// so internal vriables are kept outside (theoretically) of Svelte's binding mechansim
const DND = (() => {
let svg = null
let CTM = null
let ICTM = null
let stepSVGPoint = null
let selected = null
let offset = { x:0, y: 0}
let position = { x:0, y: 0}
// adapted from http://www.petercollingridge.co.uk/tutorials/svg/interactive/dragging/
const getMousePosition = (event) => {
if (event.touches) { event = event.touches[0]; }
let transformed = {
x: (event.clientX - CTM.e) / CTM.a,
y: (event.clientY - CTM.f) / CTM.d
}
return transformed
}
const findSVGRoot = (element) => {
const root = element.parentElement.parentElement
return root
}
const start = (event) => {
if (selected === null) {
selected = event.target
svg = findSVGRoot (selected)
CTM = svg.getScreenCTM( )
ICTM = CTM.inverse()
event.preventDefault ()
position = getMousePosition (event)
offset = { x:0, y: 0 }
bg = 'green'
}
}
const move = (event) => {
// console.log (svg)
if (selected) {
event.preventDefault ()
const npos = getMousePosition (event)
offset.x = (npos.x - position.x)
offset.y = (npos.y - position.y)
Y_COUNT = (offset.y / YSTEP)
// hold ancient position
position = npos
bg = 'red'
console.clear ()
console.log ('offset.y', offset.y)
console.log ('Y_COUNT', Y_COUNT)
// console.log ('offset.y', offset.y)
}
}
const end = (event) => {
event.preventDefault ()
selected = null
offset = null
position = null
bg = 'yellow'
}
const click = (event) => { }
return { init, start, move, end, click }
}) ()
onMount (() => {
let major_step = min
let minor_step = min
while (minor_step <= max) {
const ym = lerp (min, YMIN, max, YMAX, minor_step)
minors [ minors.length] = {
x1: 0, y1: ym,
x1: 2.5, y2: ym,
}
minor_step += minor
}
while (major_step <= max) {
const ym = lerp (min, YMIN, max, YMAX, major_step)
majors [ majors.length] = {
x1: -5, y1: ym,
x1: 7.5, y2: ym,
}
markers [ markers.length] = {
x: 10,
y: ym,
label: major_step
}
major_step += major
}
})
</script>
<h1> #{ title }</h1>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-5 -15 55 125"
width="120" height="280"
on:click={DND.click}>
<g class="ruler">
<line
x1="{ yaxis.x1 }" y1="{ yaxis.y1 }"
x2="{ yaxis.x2 }" y2="{ yaxis.y2 }"
/>
{#each majors as maj }
<line x1={maj.x1} y1={maj.y1} x2={maj.x2} y2={maj.y2} />
{/each}
{#each markers as mm_text }
<text x="{ mm_text.x }" y="{ mm_text.y }"> { mm_text.label} </text>
{/each}
{#each minors as min }
<line x1={min.x1} y1={min.y1} x2={min.x2} y2={min.y2} />
{/each}
</g>
<g
class="slider"
transform={ 'translate(0,' + (Y_VALUE) + ')'}
on:mousedown={DND.start}
on:mousemove={DND.move}
on:mouseup={DND.end}
on:mouseleave={DND.end}>
<path d={path_points} style={ 'fill:' + bg + ';' }/>
<text x="10" y="3" class="fat-arrow">{ formatted_value } </text>
</g>
</svg>
<p> Y_COUNT (internal): { Y_COUNT.toFixed(3)} </p>
<p> YSTEP (internal): { YSTEP.toFixed(3)} </p>
<p>Y_VALUE (internal): { Y_VALUE.toFixed(3)} </p>
<p>value: { value.toFixed(3)} </p>
<style>
svg {
background-color: lightgrey;
}
line {
stroke-width: 0.5;
stroke: rgb(0, 0, 255);
}
text {
font-size: 0.35em;
color: rgb(0, 0, 255);
}
text.fat-arrow {
font-size: 0.55em;
color: rgb(192, 48, 16);
font-weight: bold;
cursor: pointer;
}
path {
fill: rgb(255, 255, 64 );
stroke: black;
stroke-width: 0.5;
cursor: pointer;
}
</style>
最后我自己发现:我误会了数学...
说,我有用户输入的边界和步骤,并且同样转换为 SVG 坐标:
user input SVG
------------------------
min: 0 YMIN: 0
max: 10000 YMAX: 100
step: 100 YSTEP: ?
计算YSTEP的正确方法不是:
// NO !!!
YSTEP = lerp (min, YMIN, max, YMAX, step)
。因为我之前决定上下翻转滑块刻度,而是:
// YES !!!
YSTEP = step * (YMAX - YMIN) / (max - min)
这使我能够!!有积极(或消极)的步骤
所以我可以关闭问题。对我来说一切都很好,里加德;-)