diff --git a/assets/css/application.css b/assets/css/application.css index d2d5a5e8..1c82ed9d 100644 --- a/assets/css/application.css +++ b/assets/css/application.css @@ -38,6 +38,7 @@ @import "elements/media"; @import "elements/mobile"; @import "elements/separator"; +@import "elements/slider"; @import "elements/table"; /* Style elements specific to certain pages. */ diff --git a/assets/css/elements/slider.css b/assets/css/elements/slider.css new file mode 100644 index 00000000..87a6a002 --- /dev/null +++ b/assets/css/elements/slider.css @@ -0,0 +1,29 @@ +.slider { + display: flex; + flex: 1 0 auto; +} + +.slider__body { + background: var(--primary-color); + min-height: var(--padding-small); + min-width: 100%; +} + +.slider__head { + position: absolute; + background: var(--text-color); + width: var(--padding-normal); + height: var(--padding-normal); + border-radius: 100%; + transform: translateY(-2px); +} + +.slider__minmax { + display: flex; + flex: 1 0 auto; +} + +.slider__minmax .slider:last-child { + right: calc(100% - 2.2rem * 2); + position: relative; +} diff --git a/assets/css/views/statistics.css b/assets/css/views/statistics.css index 7f6fd1b1..7150b2c7 100644 --- a/assets/css/views/statistics.css +++ b/assets/css/views/statistics.css @@ -1,29 +1,20 @@ .statistics { display: flex; - flex-direction: column; - border: 1.5px solid var(--secondary-color); - border-radius: var(--border-radius-inner); - overflow: hidden; + flex-flow: column; + gap: var(--padding-small); } .statistics__statistic { - display: grid; - grid-template-columns: 15% 15% auto; - background: var(--secondary-color); -} - -@mixin even-odd statistics__statistic; - -.statistics__column { - text-align: center; - padding: var(--padding-small); + display: flex; + flex-flow: column; } .sparkline { - border-bottom: 1px solid var(--primary-border-color); + background: var(--primary-muted-color); + border-radius: var(--border-radius-inner); display: flex; height: var(--padding-large); - padding: var(--padding-tiny); + padding: var(--padding-small); overflow: hidden; } diff --git a/assets/js/graph.ts b/assets/js/graph.ts index 6c59a505..5014600c 100644 --- a/assets/js/graph.ts +++ b/assets/js/graph.ts @@ -6,24 +6,68 @@ import { $, $$ } from './utils/dom'; +function setGraphWidth(el: SVGSVGElement, width: number) { + el.viewBox.baseVal.width = Math.max(width, 0); + + const graph: SVGPathElement | null = $('#js-graph', el); + + if (graph) { + graph.style.transform = `scaleX(${Math.max(width, 0) / 375})`; + } +} + +function graphSlice(el: SVGSVGElement, width: number, offset: number) { + setGraphWidth(el, width); + el.viewBox.baseVal.x = Math.max(offset, 0); +} + function resizeGraphs() { - $$('#js-sparkline-svg').forEach(el => { + $$('#js-graph-svg').forEach(el => { const parent: HTMLElement | null = el.parentElement; if (parent) { - el.viewBox.baseVal.width = parent.clientWidth; - - const graph: SVGPathElement | null = $('#js-barline-graph', el); - - if (graph) { - graph.style.transform = `scaleX(${parent.clientWidth / 375})`; - } + setGraphWidth(el, parent.clientWidth); } }); } +function scaleGraph(target: HTMLElement, min: number, max: number) { + const targetSvg = $('#js-graph-svg', target); + + if (!targetSvg) { return; } + + const cw = target.clientWidth; + const diff = 100 - (max - min); + const targetWidth = cw + cw * (diff / 100); + const targetOffset = targetWidth * (min / 100); + + targetSvg.style.minWidth = `${targetWidth}px`; + + graphSlice(targetSvg, targetWidth, targetOffset); +} + +function setupSliders() { + $$('#js-graph-slider').forEach(el => { + const targetId = el.getAttribute('data-target'); + + if (!targetId) { return; } + + const target = $(targetId); + + if (!target) { return; } + + el.addEventListener('input', () => { + const min = Number(el.getAttribute('valuemin') || '0'); + const max = Number(el.getAttribute('valuemax') || '0'); + + scaleGraph(target, min, max); + }); + }); +} + function sizeGraphs() { resizeGraphs(); + setupSliders(); window.addEventListener('resize', resizeGraphs); } diff --git a/assets/js/slider.ts b/assets/js/slider.ts new file mode 100644 index 00000000..fee08a51 --- /dev/null +++ b/assets/js/slider.ts @@ -0,0 +1,164 @@ +/** + * Slider Logic + * + * Provides functionality for + * + * Example usage: + * + * + */ + +import { $$ } from './utils/dom'; + +function lerp(delta: number, from: number, to: number): number { + if (delta >= 1) { return to; } + else if (delta <= 0) { return from; } + + return from + (to - from) * delta; +} + +function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty: string, limitProperty: string) { + const parent = el.parentElement; + + if (!parent) { + return; + } + + let minPos = 0; + let maxPos = 0; + let curValue = 0; + let dragging = false; + + function initVars() { + if (!parent) { return; } + + const rect = parent.getBoundingClientRect(); + + minPos = rect.x; + maxPos = rect.x + rect.width - el.clientWidth; + curValue = Number(dataEl.getAttribute(valueProperty) || '0'); + } + + function clampValue(value: number): number { + const storedValue = Number(dataEl.getAttribute(valueProperty) || '0'); + const limitValue = Number(dataEl.getAttribute(limitProperty) || '0'); + + if (storedValue >= limitValue && value < limitValue) { + return limitValue; + } + else if (storedValue < limitValue && value >= limitValue) { + return limitValue - 1; // Offset by 1 to ensure stored value is less than limit. + } + + return value; + } + + function getMin(): number { + return Number(dataEl.getAttribute('min') || '0'); + } + + function getMax(): number { + return Number(dataEl.getAttribute('max') || '0'); + } + + // Define functions to control the drag behavior of the slider. + function dragMove(e: PointerEvent) { + if (!dragging) { return; } + + e.preventDefault(); + + let desiredPos = e.clientX; + + if (desiredPos > maxPos) { + desiredPos = maxPos; + } + else if (desiredPos < minPos) { + desiredPos = minPos; + } + + curValue = clampValue( + lerp( + (desiredPos - minPos) / (maxPos - minPos), + getMin(), + getMax() + ) + ); + + desiredPos = lerp(curValue / getMax(), minPos, maxPos); + + el.style.left = `${desiredPos}px`; + + dataEl.setAttribute(valueProperty, curValue.toString()); + dataEl.dispatchEvent(new InputEvent('input')); + } + + function dragEnd(e: PointerEvent) { + if (!dragging) { return; } + + e.preventDefault(); + + dataEl.setAttribute(valueProperty, curValue.toString()); + dataEl.dispatchEvent(new InputEvent('input')); + + dragging = false; + } + + function dragBegin(e: PointerEvent) { + if (!parent) { return; } + + e.preventDefault(); + initVars(); + + dragging = true; + } + + // Set initial position; + initVars(); + el.style.left = `${lerp(curValue / getMax(), minPos, maxPos)}px`; + + // Attach event listeners for dragging the head. + el.addEventListener('pointerdown', dragBegin); + window.addEventListener('pointerup', dragEnd); + window.addEventListener('pointermove', dragMove); +} + +function setupSlider(el: HTMLInputElement) { + const parent = el.parentElement; + + if (!parent) { + return; + } + + // Create a bunch of divs for presentation. + const sliderContainer: HTMLDivElement = document.createElement('div'); + const minHead: HTMLDivElement = document.createElement('div'); + const maxHead: HTMLDivElement = document.createElement('div'); + const body: HTMLDivElement = document.createElement('div'); + + // Hide the real input, and add CSS classes to our divs. + el.classList.add('hidden'); + sliderContainer.classList.add('slider'); + minHead.classList.add('slider__head'); + minHead.classList.add('slider__head--min'); + maxHead.classList.add('slider__head'); + maxHead.classList.add('slider__head--max'); + body.classList.add('slider__body'); + + // Insert divs into other divs and subsequently into the document. + sliderContainer.appendChild(body); + sliderContainer.appendChild(minHead); + sliderContainer.appendChild(maxHead); + parent.insertBefore(sliderContainer, el); + + // Setup drag events on head elements. + setupDrag(minHead, el, 'valuemin', 'valuemax'); + setupDrag(maxHead, el, 'valuemax', 'valuemin'); +} + +function setupSliders() { + $$('input[type="dualrange"]').forEach(el => { + setupSlider(el); + }); +} + +export { setupSliders }; diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js index ba2e6640..2eb90aa6 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.js @@ -36,6 +36,7 @@ import { pollOptionCreator } from './poll'; import { warnAboutPMs } from './pmwarning'; import { imageSourcesCreator } from './sources'; import { sizeGraphs } from './graph'; +import { setupSliders } from './slider'; whenReady(() => { @@ -69,6 +70,7 @@ whenReady(() => { pollOptionCreator(); warnAboutPMs(); imageSourcesCreator(); + setupSliders(); sizeGraphs(); }); diff --git a/lib/philomena_web/templates/profile/_statistics.html.slime b/lib/philomena_web/templates/profile/_statistics.html.slime index a5cbdbb0..c5bde598 100644 --- a/lib/philomena_web/templates/profile/_statistics.html.slime +++ b/lib/philomena_web/templates/profile/_statistics.html.slime @@ -3,26 +3,50 @@ .block__content .statistics .statistics__statistic - .statistics__column Uploads - .statistics__column = number_with_delimiter(@user.uploads_count) - .statistics__column: .sparkline = sparkline_data(@statistics.uploads) + h5 + | Uploads ( + = number_with_delimiter(@user.uploads_count) + | ) + .sparkline#js-upload-graph = sparkline_data(@statistics.uploads) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-upload-graph" .statistics__statistic - .statistics__column Favorites - .statistics__column = number_with_delimiter(@user.images_favourited_count) - .statistics__column: .sparkline = sparkline_data(@statistics.images_favourited) + h5 + | Favorites ( + = number_with_delimiter(@user.images_favourited_count) + | ) + .sparkline#js-favorite-graph = sparkline_data(@statistics.images_favourited) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-favorite-graph" .statistics__statistic - .statistics__column Comments - .statistics__column = number_with_delimiter(@user.comments_posted_count) - .statistics__column: .sparkline = sparkline_data(@statistics.comments_posted) + h5 + | Comments ( + = number_with_delimiter(@user.comments_posted_count) + | ) + .sparkline#js-comment-graph = sparkline_data(@statistics.comments_posted) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-comment-graph" .statistics__statistic - .statistics__column Votes - .statistics__column = number_with_delimiter(@user.votes_cast_count) - .statistics__column: .sparkline = sparkline_data(@statistics.votes_cast) + h5 + | Votes ( + = number_with_delimiter(@user.votes_cast_count) + | ) + .sparkline#js-vote-graph = sparkline_data(@statistics.votes_cast) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-vote-graph" .statistics__statistic - .statistics__column Metadata Updates - .statistics__column = number_with_delimiter(@user.metadata_updates_count) - .statistics__column: .sparkline = sparkline_data(@statistics.metadata_updates) + h5 + | Metadata Updates ( + = number_with_delimiter(@user.metadata_updates_count) + | ) + .sparkline#js-metadata-graph = sparkline_data(@statistics.metadata_updates) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-metadata-graph" .statistics__statistic - .statistics__column Forum Posts - .statistics__column = number_with_delimiter(@user.forum_posts_count) - .statistics__column: .sparkline = sparkline_data(@statistics.forum_posts) + h5 + | Forum Posts ( + = number_with_delimiter(@user.forum_posts_count) + | ) + .sparkline#js-post-graph = sparkline_data(@statistics.forum_posts) + .slider__minmax + input#js-graph-slider type="dualrange" min="0" max="100" valuemin="0" valuemax="100" data-target="#js-post-graph" diff --git a/lib/philomena_web/views/profile_view.ex b/lib/philomena_web/views/profile_view.ex index 8fc93f7b..e1e73f4d 100644 --- a/lib/philomena_web/views/profile_view.ex +++ b/lib/philomena_web/views/profile_view.ex @@ -54,7 +54,7 @@ defmodule PhilomenaWeb.ProfileView do sy = height / 20 factor = 100 / 90 - content_tag :svg, id: "js-sparkline-svg", width: "100%", preserveAspectRatio: "xMinYMin", viewBox: "0 0 #{width} #{height}" do + content_tag :svg, id: "js-graph-svg", width: "100%", preserveAspectRatio: "xMinYMin", viewBox: "0 0 #{width} #{height}" do first = List.first(data) last = List.last(data) first_y = sparkline_y(first, max) * sy @@ -80,7 +80,7 @@ defmodule PhilomenaWeb.ProfileView do end end - graph = content_tag :path, "", id: "js-barline-graph", class: "barline__bar", d: "M0,#{first_y}#{points}L#{width - sx},#{last_y}L#{width - sx},#{height}L0,#{height}L0,#{first_y}" + graph = content_tag :path, "", id: "js-graph", class: "barline__bar", d: "M0,#{first_y}#{points}L#{width - sx},#{last_y}L#{width - sx},#{height}L0,#{height}L0,#{first_y}" [graph, circles] end