1import { Controller } from '@hotwired/stimulus' 2import { watchMedia } from 'utils' 3 4// The inputs span the full 0..1 so their thumbs land where the visible handles are (see 5// the note in _detector.html.erb); the real bounds are enforced here instead. 6// 7// Every band keeps at least GAP, which fixes each handle's own range: the lower one lives 8// in 0.1..0.8 and the upper in 0.2..0.9, since either has to leave room for the other plus 9// the far edge. 10const STEP = 0.01 11const GAP = 0.1 12const MIN = 0.1 13const MAX = 0.9 14// One move, then a beat before the next. The transition timing lives in susfactor.css. 15const MOVE_MS = 700 16const BEAT_MS = 200 17const SEQUENCE = [[0, 0.25], [1, 0.6], [0, 0.5], [1, 0.75]] 18 19/* 20 * The three-band threshold slider. Two native ranges do the input; this only clamps them 21 * and writes --sf-t1 / --sf-t2, which the segments, the handles and the labels all read. 22 * 23 * On first sight it demonstrates itself once â the two handles walk out and back â and the 24 * first touch of either control cancels that for good, the way the hero's typing loop 25 * hands over on first click. 26 */ 27export default class extends Controller { 28 static targets = ['input', 'score'] 29 30 connect () { 31 // With JS off the inputs stay hidden, so the section is a static diagram rather than a 32 // thumb that moves nothing. This is what turns it into a control. 33 this.element.classList.add('is-interactive') 34 this.timers = [] 35 this.render() 36 37 // Watched rather than read once: turning the preference on mid-sequence used to leave the 38 // handles walking themselves, now with the transition off so they snapped from step to step. 39 this.motion = watchMedia('(prefers-reduced-motion: reduce)', () => this.sync()) 40 this.sync() 41 } 42 43 disconnect () { 44 this.motion.stop() 45 this.cancelAutoplay() 46 this.stopObserving() 47 } 48 49 /* The demo is one-shot, so turning the preference off again does not hand it back: `demoed` 50 covers both the sequence having run and the visitor having taken the controls. */ 51 sync () { 52 if (this.motion.matches) { 53 this.cancelAutoplay() 54 this.stopObserving() 55 } else if (!this.demoed && !this.observer) { 56 this.observe() 57 } 58 } 59 60 observe () { 61 this.observer = new IntersectionObserver(([entry]) => { 62 if (!entry.isIntersecting) return 63 this.stopObserving() 64 this.autoplay() 65 }, { threshold: 0, rootMargin: '0px 0px -30% 0px' }) 66 this.observer.observe(this.element) 67 } 68 69 stopObserving () { 70 this.observer?.disconnect() 71 this.observer = null 72 } 73 74 autoplay () { 75 this.demoed = true 76 SEQUENCE.forEach(([index, value], step) => { 77 this.timers.push(setTimeout(() => { 78 this.inputTargets[index].value = value 79 this.render() 80 }, step * (MOVE_MS + BEAT_MS))) 81 }) 82 } 83 84 cancelAutoplay () { 85 this.timers.forEach(clearTimeout) 86 this.timers = [] 87 } 88 89 /* Any touch of either handle ends the demo, and drops the tween so the drag tracks. */ 90 takeOver () { 91 this.demoed = true 92 this.cancelAutoplay() 93 this.stopObserving() 94 this.element.classList.add('is-dragging') 95 } 96 97 /* And puts the tween back when the interaction ends. Left on, the class killed the 700ms 98 transition for the rest of the page's life, so every later move snapped. */ 99 endDrag () { 100 this.element.classList.remove('is-dragging') 101 } 102 103 change (event) { 104 this.takeOver() 105 this.render(event.target) 106 } 107 108 /* 109 * Each handle is held inside its own range first, which is what stops the one being 110 * dragged: the lower cannot pass 0.8, the upper cannot fall below 0.2. 111 * 112 * From there the one that moved pushes the other ahead of it rather than stopping dead 113 * against it â drag the lower handle right and the upper travels with it, both coming to 114 * rest at 0.8 and 0.9. One pass is enough: a handle inside its own range always leaves 115 * room for its neighbour inside theirs. 116 * 117 * `moved` comes from the event rather than document.activeElement, which not every 118 * browser sets on a pointer drag. 119 */ 120 render (moved = null) { 121 const [lower, upper] = this.inputTargets 122 let t1 = clamp(parseFloat(lower.value), MIN, MAX - GAP) 123 let t2 = clamp(parseFloat(upper.value), MIN + GAP, MAX) 124 125 if (t2 - t1 < GAP) { 126 if (moved === upper) t1 = snap(t2 - GAP) 127 else t2 = snap(t1 + GAP) 128 } 129 130 lower.value = t1 131 upper.value = t2 132 lower.setAttribute('aria-valuetext', `Review from ${format(t1)}`) 133 upper.setAttribute('aria-valuetext', `Auto block from ${format(t2)}`) 134 135 this.element.style.setProperty('--sf-t1', t1) 136 this.element.style.setProperty('--sf-t2', t2) 137 this.scoreTargets[0].textContent = format(t1) 138 this.scoreTargets[1].textContent = format(t2) 139 } 140} 141 142// Rounds to the step *and* off the float noise it arrives with: MAX - GAP is 143// 0.8000000000000001, and that would reach both the input's value and --sf-t1. 144function snap (value) { 145 return Number((Math.round(value / STEP) * STEP).toFixed(2)) 146} 147 148function clamp (value, low, high) { 149 return snap(Math.min(high, Math.max(low, value))) 150} 151 152// Stepping by 0.01 off a float lands on things like 0.30000000000000004; two decimals and 153// back through Number drops both the noise and the trailing zero, so 0.5 reads as the 154// design's "0.5" rather than "0.50". 155function format (value) { 156 return String(Number(value.toFixed(2))) 157}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.