The element containing a draggable
or moveable
handle can only start from the top
left
position, I have no idea how to adjust, calculate or correct the pixel values to make the draggable handle element start from say the bottom left like a normal graph would.
The position of the handle
is used to generate a range restricted value like a percentage value between 0-100
for both the x
and y
axis regardless of the element's pixel size.
It's a range-input or position picker of sorts intended for use in a color picker widget.
The color gradients change depending on the widget's relative position to the left, top or right of something, hence the picker or handle should adjust the starting point of it's range accordingly.
I'm using onpointermove
to get the x
and y
positions of the div.handle
and
adjust for the relative width
, height
, left
and top
, offsets of the parent element.
What I cannot figure out for the life of me is the math and code needed to allow the range input to track the position from an arbitrary corner, preferably bottom
left
.
Sorry for using a custom library but this example is mostly vanilla, at least the calculations which matter are.
const {dom, component, each, on, once, isNum, $, run} = rilti
// keep a number between a minimum and maximum ammount
const clamp = (n, min, max) => Math.min(Math.max(n, min), max)
// define element behavior
component('range-input', {
// set up everything before element touches DOM
create (range /* Proxy<Function => Element> */) {
// setup zero values in state (observer-like abstraction tracking changes)
range.state({value: 0, valueX: 0, valueY: 0})
// local vars for easier logic
let Value, ValueY
// create element <div class="handle"> and append to <range-input>
// also add property to range and get it as a const
const handle = range.handle = dom.div.handle({$: range})
// set the range limits at 0-100% by default for X and Y axis
if (range.limit == null) range.limitX = range.limit = 100
if (range.limit !== range.limitX) range.limitX = range.limit
if (range.limitY == null) range.limitY = range.limit
// set the X position by percentage/range number,
// move the handle accordingly and change state
range.setX = (value = range.value || 0, skipChecks) => {
if (!skipChecks && value === Value) return
if (value > range.limitX || value < 0) throw new Error('value out of range')
// if the element is not in the dom
// then wait for it to mount first
if (!range.mounted) {
range.once.mount(e => range.setX(value))
return
}
// allow float values or round it to ints by default
if (!range.decimals) value = Math.round(value)
const hWidth = handle.offsetWidth
// get pixel range
const Min = hWidth / 2
const Max = range.offsetWidth - Min
// calculate pixel postion from range value
const hLeft = (value / range.limitX) * (Max - Min)
handle.style.left = hLeft + 'px'
// update all the states
Value = range.state.value = range.state.valueX = value
}
// same as setX but for Y axis
range.setY = (value = range.valueY || 0, skipChecks) => {
if (!skipChecks && value === Value) return
if (value > range.limitY || value < 0) throw new Error('value out of range')
if (!range.mounted) {
range.once.mount(e => range.setY(value))
return
}
const hHeight = handle.offsetHeight
const Min = hHeight / 2
const Max = range.offsetHeight - Min
const hTop = (value / range.limitY) * (Max - Min)
handle.style.top = hTop + 'px'
if (!range.decimals) value = Math.round(value)
ValueY = range.state.valueY = value
}
// get the raw Element/Node and define (s/g)etters
Object.defineProperties(range() /* -> <range-input> */, {
value: {get: () => Value, set: range.setX},
valueX: {get: () => Value, set: range.setX},
valueY: {get: () => ValueY, set: range.setY}
})
let rWidth // range.offsetWidth
let rHeight // range.offsetHeight
let rRect // cache of range.getBoundingClientRect()
// called when user moves the handle
const move = (x = 0, y = 0) => {
// check the the axis is not locked
// for when you want to use range-input as a slider
if (!range.lockX) {
// adjust for relative position
if (x < rRect.left) x = rRect.left
else if (x > rRect.left + rWidth) x = rRect.left + rWidth
x -= rRect.left
const hWidth = handle.offsetWidth
// get pixel range
const min = hWidth / 2
const max = rWidth - min
// keep it inside the block
const hLeft = clamp(x, min, max) - min
handle.style.left = hLeft + 'px'
// pixel position -> percentage/value
let value = (hLeft * range.limitX) / (max - min)
// round value to an int by default
if (!range.decimals) value = Math.round(value)
// set it if it's not the same as the old value
if (value !== Value) {
Value = range.state.value = range.state.valueX = value
}
}
// now do below as above for Y axis
if (!range.lockY) { // when it's not locked
if (y < rRect.top) y = rRect.top
else if (y > rRect.top + rWidth) y = rRect.top + rHeight
y -= rRect.top
const hHeight = handle.offsetHeight
const min = hHeight / 2
const max = range.offsetHeight - min
const hTop = clamp(y, min, max) - min
handle.style.top = hTop + 'px'
let value = (hTop * range.limitY) / (max - min)
if (!range.decimals) value = Math.round(value)
if (value !== ValueY) {
ValueY = range.state.valueY = value
}
}
// .dispatchEvent(new CustomEvent('input'))
range.emit('input')
// call an update function if it's present as a prop
if (range.update) range.update(range, handle)
}
// track and manage starting, stopping and moving events
// for .pointer(up/down/move) event types respectively.
const events = range.state.events = {
move: on.pointermove(document, e => move(e.x, e.y)).off(),
stop: on.pointerup(document, () => {
events.move.off()
events.start.on()
}).off(),
start: once.pointerdown([range, handle], () => {
[rWidth, rHeight] = [range.offsetWidth, range.offsetHeight]
rRect = range.getBoundingClientRect()
events.move.on()
events.stop.on()
}).off()
}
// ^-- all the events are off at the start
// they get turned on when the element mounts
},
// when Element enters DOM set the positions
mount (range) {
if (!range.lockY) range.handle.style.top = 0
range.setX()
range.setY()
// start listening for user interactions
range.state.events.start.on()
},
// start listening again on DOM re-entry
remount (range) {
range.state.events.start.on()
},
// stop listening when removed from DOM
unmount ({state: {events}}) { each(events, e => e.off()) },
// track custom attribute to set some props conveniently
attr: {
opts (range, val) {
run(() => // wait for DOMContentLoaded first
val.split(';')
.filter(v => v != null && v.length)
.map(pair => pair.trim().split(':').map(part => part.trim()))
.forEach(([prop, value]) => {
if (value.toLowerCase() === 'true') value = true
else if (value.toLowerCase() === 'false') value = false
else {
const temp = Number(value)
if (isNum(temp)) value = temp
}
if (prop === 'x' || prop === 'v') {
range.setX(value, true)
} else if (prop === 'y') {
range.setY(value, true)
} else {
range[prop] = value
}
})
)
}
}
})
// show the values of the range-input
$('span.stats').append($('range-input').state`
X: ${'valueX'}%, Y: ${'valueY'}%
`)
// add a title
dom.h4('<range-input>: custom element').prependTo('body')
range-input {
position: relative;
display: block;
margin: 1em auto;
width: 250px;
height: 250px;
border: 1px solid #ccc;
}
range-input > div.handle {
position: absolute;
background: #ccc;
width: 20px;
height: 20px;
cursor: grab;
user-drag: none;
user-select: none;
touch-action: none;
}
.details {
width: 225px;
text-align: left;
margin: 3em auto;
}
* {
box-sizing: border-box;
}
body {
text-align: center;
color: hsl(0,0%,40%);
}
h4 {
margin: 0 auto;
}
<range-input opts="x: 35; y: 80;"></range-input>
<span class="stats"></span>
<section class="details">
<p>
<b>Please Help:</b><br>
I can't figure out how to code it so that
the range-input could start at an arbitrary corner
instead of just top left.
I'd like it to start counting from bottom left instead.
</p>
<pre style="text-align: left;"><code>
// the handle should be able to start at
left: 0;
bottom: 0;
// with X/Y being zero;
// not sure how to achieve this.
</code></pre>
</section>
<script src="https://rawgit.com/SaulDoesCode/rilti.js/experimental/dist/rilti.js"></script>
Same example on Codepen
Another way to describe your issue is that your y-axis goes from 100 (technically, limitY) to 0 when it ought to go from 0 to 100. Therefore, we can slightly change your code to reverse this axis by fully calculating the y percentage, and then subracting it from 100. (ie, 100 - 80 = 20 or 100 - 35 - 65.) This will change the high values into low values and vice versa. Then, if we want to convert from percentage to pixel, we simply subtract it from 100 again to get our original flipped percentage (that you've already done all of the work for.)
The two lines changed are:
const hTop = (value / range.limitY) * (Max - Min)
becomes
const hTop = (1 - value / range.limitY) * (Max - Min)
// 1 - value / range.limitY is a shortening of (range.limitY - value) / range.limitY
and
let value = (hTop * range.limitY) / (max - min)
becomes
let value = range.limitY * (1 - hTop / (max - min))
// this is also a shortening, you could have written it,
// value = range.limitY - (hTop * range.limitY) / (max - min)
Here's the Codepen.
Likewise, if you want to flip the x axis, you can use similar logic on that part of the code. You can flip various combinations of the two axes to start at various corners.
A harder version of the same problem (a good exercise for practice) is how to properly convert from your pixel, not just to a percentage, but also to any range a
to b
, with b
possibly being smaller than a
.