diff --git a/Gemfile b/Gemfile index 8f6d915df..2c2566e8b 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,7 @@ group :development do gem "bundler-audit" gem "herb" gem "hotwire-spark" + gem "htmlbeautifier" gem "kamal" gem "letter_opener" gem "reactionview" diff --git a/Gemfile.lock b/Gemfile.lock index caa10b1fd..f6a7a9fab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -194,6 +194,7 @@ GEM listen rails (>= 7.0.0) zeitwerk + htmlbeautifier (1.4.3) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -595,6 +596,7 @@ DEPENDENCIES factory_bot_rails herb hotwire-spark + htmlbeautifier image_processing importmap-rails inline_svg diff --git a/app/javascript/application.js b/app/javascript/application.js index a94a51626..f10eb1ff2 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -3,5 +3,4 @@ import "@hotwired/turbo-rails" import "@rails/activestorage" import "controllers" -import "charts" import "custom" diff --git a/app/javascript/charts/index.js b/app/javascript/charts/index.js deleted file mode 100644 index b71bc1c32..000000000 --- a/app/javascript/charts/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import "charts/shot" -import "charts/stats" diff --git a/app/javascript/charts/shot.js b/app/javascript/charts/shot.js deleted file mode 100644 index db06365cc..000000000 --- a/app/javascript/charts/shot.js +++ /dev/null @@ -1,403 +0,0 @@ -import Highcharts from "highcharts" -import "highcharts-annotations" - -const deepMerge = (obj1, obj2) => { - const result = { ...obj1 } - for (const key in obj2) { - if (!obj2.hasOwnProperty(key)) return - - if (obj2[key] instanceof Object && obj1[key] instanceof Object) { - result[key] = deepMerge(obj1[key], obj2[key]) - } else { - result[key] = obj2[key] - } - } - return result -} - -const isObject = obj => obj && typeof obj === "object" - -const syncMouseEvents = element => { - element.addEventListener("mousemove", syncMouse) - element.addEventListener("touchstart", syncMouse) - element.addEventListener("touchmove", syncMouse) - element.addEventListener("mouseleave", mouseLeave) -} - -const getHoverPoint = (chart, e) => { - return chart.pointer.findNearestKDPoint(chart.series, true, chart.pointer.normalize(e)) -} - -function syncMouse(e) { - Highcharts.charts.forEach(chart => { - if (!isObject(chart) || this === chart.renderTo) return - - const hoverPoint = getHoverPoint(chart, e) - const hoverPoints = [] - - if (hoverPoint) { - chart.series.forEach(s => { - if (!s.visible) return - - const point = s.points.find(p => p.x === hoverPoint.x && !p.isNull) - if (isObject(point)) { - hoverPoints.push(point) - } - }) - } - - if (hoverPoints.length) { - chart.tooltip.refresh(hoverPoints) - chart.xAxis[0].drawCrosshair(e, hoverPoints[0]) - } - }) -} - -const mouseLeave = e => { - Highcharts.charts.forEach(chart => { - if (!isObject(chart)) return - - const hoverPoint = getHoverPoint(chart, e) - - if (hoverPoint) { - hoverPoint.onMouseOut() - chart.tooltip.hide(hoverPoint) - chart.xAxis[0].hideCrosshair() - } - }) -} - -const syncZoomReset = e => { - if (!e.resetSelection) return - - Highcharts.charts.forEach(chart => { - if (!isObject(chart) || !isObject(chart.resetZoomButton)) return - chart.resetZoomButton = chart.resetZoomButton.destroy() - }) -} - -const syncExtremes = function (e) { - if (e.trigger === "syncExtremes") return - - Highcharts.charts.forEach(chart => { - if (!isObject(chart) || chart === this.chart) return - if (!chart.xAxis[0].setExtremes) return - - chart.xAxis[0].setExtremes(e.min, e.max, undefined, false, { trigger: "syncExtremes" }) - if (isObject(chart.resetZoomButton) || e.min === undefined || e.max === undefined) return - - chart.showResetZoom() - }) -} - -const isDark = () => { - if (document.body.classList.contains("system")) { - return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches - } else { - return document.body.classList.contains("dark") - } -} - -const getColors = () => { - if (isDark()) { - return { - background: "#171717", - label: "#999999", - gridLine: "#191919", - line: "#332914", - legend: "#cccccc", - legendHover: "#ffffff", - legendHidden: "#333333" - } - } else { - return { - background: "#ffffff", - label: "#666666", - gridLine: "#e6e6e6", - line: "#ccd6eb", - legend: "#333333", - legendHover: "#000000", - legendHidden: "#cccccc" - } - } -} - -const commonOptions = () => { - const colors = getColors() - - return { - accessibility: { enabled: false }, - animation: false, - title: false, - chart: { - zoomType: "x", - backgroundColor: colors.background, - events: { selection: syncZoomReset } - }, - xAxis: { - type: "datetime", - events: { setExtremes: syncExtremes }, - crosshair: true, - labels: { - style: { color: colors.label }, - formatter: function () { - if (this.value < 0) { - return `-${Highcharts.dateFormat("%M:%S", -this.value)}` - } else { - return Highcharts.dateFormat("%M:%S", this.value) - } - } - }, - gridLineColor: colors.gridLine, - lineColor: colors.line, - tickColor: colors.line - }, - yAxis: { - title: false, - labels: { style: { color: colors.label } }, - gridLineColor: colors.gridLine, - lineColor: colors.line, - tickColor: colors.line - }, - tooltip: { - animation: false, - shared: true, - borderRadius: 10, - shadow: false, - borderWidth: 0, - formatter: function (tooltip) { - let s - if (this.x < 0) { - s = [`-${Highcharts.dateFormat("%M:%S.%L", -this.x)}
`] - } else { - s = [`${Highcharts.dateFormat("%M:%S.%L", this.x)}
`] - } - - const visibleSeries = this.points.filter(point => point.series.visible) - if (visibleSeries.length === 2) { - const [series1, series2] = visibleSeries - const diff = Math.abs(series1.y - series2.y) - const formattedDiff = Highcharts.numberFormat(diff, 2) - s = s.concat(tooltip.bodyFormatter(visibleSeries)) - s.push(`Δ ${formattedDiff}`) - } else { - s = s.concat(tooltip.bodyFormatter(this.points)) - } - - return s - } - }, - legend: { - itemStyle: { color: colors.legend }, - itemHoverStyle: { color: colors.legendHover }, - itemHiddenStyle: { color: colors.legendHidden } - }, - plotOptions: { - series: { - animation: false, - marker: { - enabled: false, - states: { hover: { enabled: false } } - }, - states: { - hover: { enabled: false }, - inactive: { enabled: false } - } - } - }, - credits: { enabled: false } - } -} - -const extractStages = (field, timings) => { - for (const fieldData of window.shotData) { - if (fieldData.name === field) { - return timings.map(time => new Map(fieldData.data).get(time)) - } - } - return timings.map(() => 0) -} - -function setupInCupAnnotations(chart) { - const weightColor = chart.series.find(x => x.name === "Weight Flow").color - const timings = shotStages.map(x => x.value) - const weightFlow = extractStages("Weight Flow", timings) - const weight = extractStages("Weight", timings) - - let labels = [] - timings.forEach((timing, index) => { - const inCup = weight[index] - if (inCup > 0) { - labels.push({ - text: `${inCup}g`, - point: { x: timing, y: weightFlow[index], xAxis: 0, yAxis: 0 }, - y: -30, - x: -30, - allowOverlap: true, - style: { color: weightColor }, - borderColor: weightColor - }) - } - }) - - chart.inCupAnnotation = chart.addAnnotation({ - draggable: false, - labels: labels, - labelOptions: { shape: "connector" } - }) - - chart.renderer.text('', + 50, + 35, + true + ) + .attr({ zIndex: 3 }) + .add() + chart.annotationVisible = true + const toggleButton = chart.annotationButton.element.querySelector("button") + if (!toggleButton) return + + chart.annotationToggleHandler = () => { + if (!chart.annotations || !chart.annotations[0] || !chart.annotations[0].graphic) return + + if (chart.annotationVisible) { + chart.controller.destroyShotStages() + chart.annotations[0].graphic.hide() + chart.annotationVisible = false + toggleButton.textContent = "Show annotations" + } else { + chart.controller.drawShotStages() + chart.annotations[0].graphic.show() + chart.annotationVisible = true + toggleButton.textContent = "Hide annotations" + } + } + + toggleButton.addEventListener("click", chart.annotationToggleHandler) + } + + updateInCupVisibility(chart) { + const weightFlowSeries = chart.series.filter(x => x.name === "Weight Flow" && x.visible) + const isVisible = weightFlowSeries.length > 0 + const annotation = chart.inCupAnnotation + + if (annotation && !isVisible) { + chart.removeAnnotation(annotation) + chart.inCupAnnotation = null + chart.annotationVisible = false + if (chart.annotationButton) { + const toggleButton = chart.annotationButton.element.querySelector("button") + if (toggleButton && chart.annotationToggleHandler) { + toggleButton.removeEventListener("click", chart.annotationToggleHandler) + } + chart.annotationButton.destroy() + chart.annotationButton = null + chart.annotationToggleHandler = null + } + } else if (this.shotStagesNormalized.length > 0 && !annotation && isVisible) { + this.setupInCupAnnotations(chart) + } + } + + drawShotChart(element) { + const hasSecondaryAxis = this.shotDataValue.some(series => series.yAxis === 1) + const custom = { + chart: { + height: 650, + events: { redraw: e => this.updateInCupVisibility(e.target) } + }, + series: this.shotDataValue + } + + let options = deepMerge(commonOptions(), custom) + if (hasSecondaryAxis) { + const primaryAxis = options.yAxis + options.yAxis = [ + primaryAxis, + { + ...primaryAxis, + opposite: true, + gridLineWidth: 0 + } + ] + } + const chart = Highcharts.chart(element, options) + chart.controller = this + if (this.shotStagesNormalized.length > 0) { + this.setupInCupAnnotations(chart) + } + return chart + } + + drawShotStages() { + this.charts.forEach(chart => { + if (isObject(chart)) { + this.shotStagesNormalized.forEach(x => { + chart.xAxis[0].addPlotLine(x) + }) + } + }) + } + + destroyShotStages() { + this.charts.forEach(chart => { + if (isObject(chart)) { + while (chart.xAxis[0].plotLinesAndBands.length > 0) { + chart.xAxis[0].plotLinesAndBands.forEach(plotLine => { + chart.xAxis[0].removePlotLine(plotLine.id) + }) + } + } + }) + } + + drawTemperatureChart(element) { + const custom = { + chart: { + height: 400 + }, + series: this.temperatureDataValue + } + + let options = deepMerge(commonOptions(), custom) + return Highcharts.chart(element, options) + } + + applyComparisonOffset(value) { + if (Object.keys(this.comparisonDataValue).length === 0) return + + this.charts.forEach(chart => { + if (!isObject(chart)) return + + chart.series.forEach(s => { + if (!this.comparisonDataValue[s.name]) return + + s.setData( + this.comparisonDataValue[s.name].map(d => [d[0] + value, d[1]]), + true, + false, + false + ) + }) + }) + } + + onCompareInput() { + if (!this.hasCompareRangeTarget) return + + const value = parseInt(this.compareRangeTarget.value) + this.applyComparisonOffset(value) + } + + onCompareReset(event) { + event.preventDefault() + if (!this.hasCompareRangeTarget) return + + this.compareRangeTarget.value = 0 + this.applyComparisonOffset(0) + } + + handleColorSchemeChange() { + if (document.body.classList.contains("system")) { + this.initializeCharts() + } + } +} diff --git a/app/javascript/controllers/stats_charts_controller.js b/app/javascript/controllers/stats_charts_controller.js new file mode 100644 index 000000000..0ac25a4ed --- /dev/null +++ b/app/javascript/controllers/stats_charts_controller.js @@ -0,0 +1,58 @@ +import { Controller } from "@hotwired/stimulus" +import Highcharts from "highcharts" + +export default class extends Controller { + static targets = ["uploadedChart", "userChart"] + static values = { + uploadedChartData: Array, + userChartData: Array + } + + connect() { + this.charts = [] + this.initializeCharts() + } + + disconnect() { + this.charts.forEach(chart => { + chart.destroy() + }) + this.charts = [] + } + + initializeCharts() { + const options = { + accessibility: { enabled: false }, + chart: { + zoomType: "x", + height: 500 + }, + xAxis: { + type: "datetime" + }, + title: false, + yAxis: { + title: false + }, + credits: { + enabled: false + } + } + + if (this.hasUploadedChartTarget && this.uploadedChartDataValue.length > 0) { + const chart = Highcharts.chart(this.uploadedChartTarget, { + ...options, + series: this.uploadedChartDataValue + }) + this.charts.push(chart) + } + + if (this.hasUserChartTarget && this.userChartDataValue.length > 0) { + const chart = Highcharts.chart(this.userChartTarget, { + ...options, + series: this.userChartDataValue + }) + this.charts.push(chart) + } + } +} diff --git a/app/javascript/helpers/object_helpers.js b/app/javascript/helpers/object_helpers.js new file mode 100644 index 000000000..4e0f7dd11 --- /dev/null +++ b/app/javascript/helpers/object_helpers.js @@ -0,0 +1,15 @@ +export const deepMerge = (obj1, obj2) => { + const result = { ...obj1 } + for (const key in obj2) { + if (!Object.hasOwn(obj2, key)) continue + + if (obj2[key] instanceof Object && obj1[key] instanceof Object) { + result[key] = deepMerge(obj1[key], obj2[key]) + } else { + result[key] = obj2[key] + } + } + return result +} + +export const isObject = obj => obj && typeof obj === "object" diff --git a/app/javascript/helpers/shot_chart_helpers.js b/app/javascript/helpers/shot_chart_helpers.js new file mode 100644 index 000000000..b1c6a80bf --- /dev/null +++ b/app/javascript/helpers/shot_chart_helpers.js @@ -0,0 +1,148 @@ +import Highcharts from "highcharts" +import { isObject } from "helpers/object_helpers" + +export const getHoverPoint = (chart, e) => { + return chart.pointer.findNearestKDPoint(chart.series, true, chart.pointer.normalize(e)) +} + +export const syncZoomReset = e => { + if (!e.resetSelection) return + + Highcharts.charts.forEach(chart => { + if (!isObject(chart) || !isObject(chart.resetZoomButton)) return + chart.resetZoomButton = chart.resetZoomButton.destroy() + }) +} + +export const syncExtremes = function (e) { + if (e.trigger === "syncExtremes") return + + Highcharts.charts.forEach(chart => { + if (!isObject(chart) || chart === this.chart) return + if (!chart.xAxis[0].setExtremes) return + + chart.xAxis[0].setExtremes(e.min, e.max, undefined, false, { trigger: "syncExtremes" }) + if (isObject(chart.resetZoomButton) || e.min === undefined || e.max === undefined) return + + chart.showResetZoom() + }) +} + +export const isDark = () => { + if (document.body.classList.contains("system")) { + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + } else { + return document.body.classList.contains("dark") + } +} + +export const getColors = () => { + if (isDark()) { + return { + background: "#171717", + label: "#999999", + gridLine: "#191919", + line: "#332914", + legend: "#cccccc", + legendHover: "#ffffff", + legendHidden: "#333333" + } + } else { + return { + background: "#ffffff", + label: "#666666", + gridLine: "#e6e6e6", + line: "#ccd6eb", + legend: "#333333", + legendHover: "#000000", + legendHidden: "#cccccc" + } + } +} + +export const commonOptions = () => { + const colors = getColors() + + return { + accessibility: { enabled: false }, + animation: false, + title: false, + chart: { + zoomType: "x", + backgroundColor: colors.background, + events: { selection: syncZoomReset } + }, + xAxis: { + type: "datetime", + events: { setExtremes: syncExtremes }, + crosshair: true, + labels: { + style: { color: colors.label }, + formatter: function () { + if (this.value < 0) { + return `-${Highcharts.dateFormat("%M:%S", -this.value)}` + } else { + return Highcharts.dateFormat("%M:%S", this.value) + } + } + }, + gridLineColor: colors.gridLine, + lineColor: colors.line, + tickColor: colors.line + }, + yAxis: { + title: false, + labels: { style: { color: colors.label } }, + gridLineColor: colors.gridLine, + lineColor: colors.line, + tickColor: colors.line + }, + tooltip: { + animation: false, + shared: true, + borderRadius: 10, + shadow: false, + borderWidth: 0, + formatter: function (tooltip) { + let s + if (this.x < 0) { + s = [`-${Highcharts.dateFormat("%M:%S.%L", -this.x)}
`] + } else { + s = [`${Highcharts.dateFormat("%M:%S.%L", this.x)}
`] + } + + const visibleSeries = this.points.filter(point => point.series.visible) + if (visibleSeries.length === 2) { + const [series1, series2] = visibleSeries + const diff = Math.abs(series1.y - series2.y) + const formattedDiff = Highcharts.numberFormat(diff, 2) + s = s.concat(tooltip.bodyFormatter(visibleSeries)) + s.push(`Δ ${formattedDiff}`) + } else { + s = s.concat(tooltip.bodyFormatter(this.points)) + } + + return s + } + }, + legend: { + itemStyle: { color: colors.legend }, + itemHoverStyle: { color: colors.legendHover }, + itemHiddenStyle: { color: colors.legendHidden } + }, + plotOptions: { + series: { + animation: false, + marker: { + enabled: false, + states: { hover: { enabled: false } } + }, + states: { + hover: { enabled: false }, + inactive: { enabled: false } + } + } + }, + credits: { enabled: false } + } +} diff --git a/app/models/shot_chart.rb b/app/models/shot_chart.rb index d1b74ea81..45eb6bcce 100644 --- a/app/models/shot_chart.rb +++ b/app/models/shot_chart.rb @@ -11,10 +11,6 @@ def initialize(shot, user = nil) prepare_chart_data end - def data? - shot_chart.present? || temperature_chart.present? - end - memo_wise def shot_chart for_highcharts(@processed_shot_data.sort.reject { |key, _v| key.include?("temperature") }) end diff --git a/app/views/canonical/autocomplete_coffee_bags.html.erb b/app/views/canonical/autocomplete_coffee_bags.html.erb index b8ebb5359..48914da1d 100644 --- a/app/views/canonical/autocomplete_coffee_bags.html.erb +++ b/app/views/canonical/autocomplete_coffee_bags.html.erb @@ -3,7 +3,7 @@
  • <%= coffee_bag.name %><% if params[:roaster_id].blank? %> (<%= coffee_bag.canonical_roaster.name %>) <% else %> -
    +
    <% end %>
  • <% end %> diff --git a/app/views/shots/compare.html.erb b/app/views/shots/compare.html.erb index 4142a4a3d..5794ea29d 100644 --- a/app/views/shots/compare.html.erb +++ b/app/views/shots/compare.html.erb @@ -9,38 +9,38 @@ <% if @chart %> - <% if Current.user&.premium? %> -
    -
    - Adjust comparison timing -
    -
    -
    - -
    -
    - -<%= ActiveSupport::Duration.build(@chart.duration / 1000).inspect %> -
    -
    -
    - <%= ActiveSupport::Duration.build(@chart.duration / 1000).inspect %> +
    + <% if Current.user&.premium? %> +
    +
    + Adjust comparison timing +
    +
    +
    + +
    +
    + -<%= ActiveSupport::Duration.build(@chart.duration / 1000).inspect %> +
    +
    +
    + <%= ActiveSupport::Duration.build(@chart.duration / 1000).inspect %> +
    +
    -
    + <% end %> +
    +
    +
    +
    +
    - <% end %> -
    -
    -
    -
    -
    - <% end %> diff --git a/app/views/shots/show.html.erb b/app/views/shots/show.html.erb index 9fe2fceae..a994206ed 100644 --- a/app/views/shots/show.html.erb +++ b/app/views/shots/show.html.erb @@ -28,7 +28,7 @@
    <% end %>
    - +
    @@ -42,37 +42,15 @@
    <% if @chart %> - <% if @chart.data? %> +
    -
    +
    - <% if @chart.temperature_chart.present? %> -
    -
    -
    - <% end %> - - <% else %> -
    -
    -
    - - - -
    -
    -

    Something is missing 🤷‍♂️

    -
    -

    - There's no data on this shot. If you think this is an error, please open an issue with the original shot/json file. -

    -
    -
    -
    +
    +
    - <% end %> +
    <% end %> diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index e22ced1ff..99c08aa3b 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -5,15 +5,14 @@
    -
    +
    Visualizer has <%= @user_count %> users who've uploaded <%= @shot_count %> shots -
    -
    +
    +
    - diff --git a/config/importmap.rb b/config/importmap.rb index 5da1533a8..f9154fc5d 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -24,5 +24,4 @@ pin_all_from "app/javascript/channels", under: "channels" pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" -pin_all_from "app/javascript/charts", under: "charts" pin_all_from "app/javascript/custom", under: "custom"