diff --git a/website/assets/css/cannlytics.scss b/website/assets/css/cannlytics.scss index c026fbb..08741b2 100644 --- a/website/assets/css/cannlytics.scss +++ b/website/assets/css/cannlytics.scss @@ -1056,23 +1056,33 @@ body.dark .product-card:hover { } /* Cannabinoids Chart CSS */ - -#cannabinoidChart { - font-family: 'Open Sans', sans-serif; - font-size: 11px; - font-weight: 300; - fill: #242424; - text-align: center; - text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff; -} -.legend { - font-family: 'Raleway', sans-serif; - fill: #333333; -} -.tooltip { - fill: #333333; -} -.radar-chart-wrapper { - margin: 20px auto; - max-width: 800px; -} +.bar-tooltip { + position: absolute; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + opacity: 0; +} + +// #cannabinoidChart { +// font-family: 'Open Sans', sans-serif; +// font-size: 11px; +// font-weight: 300; +// fill: #242424; +// text-align: center; +// text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff; +// } +// .legend { +// font-family: 'Raleway', sans-serif; +// fill: #333333; +// } +// .tooltip { +// fill: #333333; +// } +// .radar-chart-wrapper { +// margin: 20px auto; +// max-width: 800px; +// } diff --git a/website/assets/js/strains/RadarChart.js b/website/assets/js/strains/RadarChart.js index 3d4427c..aff470c 100644 --- a/website/assets/js/strains/RadarChart.js +++ b/website/assets/js/strains/RadarChart.js @@ -69,7 +69,7 @@ export function RadarChart(id, data, options) { ///////////////////////////////////////////////////////// //Filter for the outside glow - var filter = g.append('defs').append('filter').attr('id','glow'), + var filter = g.append('defs').append('filter').attr('id',`glow-${id}`), feGaussianBlur = filter.append('feGaussianBlur').attr('stdDeviation','2.5').attr('result','coloredBlur'), feMerge = filter.append('feMerge'), feMergeNode_1 = feMerge.append('feMergeNode').attr('in','coloredBlur'), @@ -92,7 +92,7 @@ export function RadarChart(id, data, options) { .style("fill", "#CDCDCD") .style("stroke", "#CDCDCD") .style("fill-opacity", cfg.opacityCircles) - .style("filter" , "url(#glow)"); + .style("filter" , `url(#glow-${id})`); //Text indicating at what % each level is axisGrid.selectAll(".axisLabel") @@ -188,7 +188,7 @@ export function RadarChart(id, data, options) { .style("stroke-width", cfg.strokeWidth + "px") .style("stroke", function(d,i) { return cfg.color(i); }) .style("fill", "none") - .style("filter" , "url(#glow)"); + .style("filter" , `url(#glow-${id})`); //Append the circles blobWrapper.selectAll(".radarCircle") diff --git a/website/assets/js/strains/cannabinoidsChart.js b/website/assets/js/strains/cannabinoidsChart.js index cd4bd6a..1bc00bf 100644 --- a/website/assets/js/strains/cannabinoidsChart.js +++ b/website/assets/js/strains/cannabinoidsChart.js @@ -26,12 +26,15 @@ export function renderCannabinoidChart(strainData) { width = Math.min(700, window.innerWidth - 10) - margin.left - margin.right, height = Math.min(width, window.innerHeight - margin.top - margin.bottom - 20); + // FIXME: Specify the `maxValue` based on the data. + const maxValue = 0.3; + // Configure the radar chart options const radarChartOptions = { w: width, h: height, margin: margin, - maxValue: 0.05, // Typical max percentage for cannabinoids + maxValue: maxValue, // Typical max percentage for cannabinoids levels: 5, // Number of circles in the radar roundStrokes: true, // Make the shape smoother color: d3.scale.ordinal().range(['#2E8B57']), // Sea Green color diff --git a/website/assets/js/strains/resultsBarChart.js b/website/assets/js/strains/resultsBarChart.js new file mode 100644 index 0000000..6983d54 --- /dev/null +++ b/website/assets/js/strains/resultsBarChart.js @@ -0,0 +1,434 @@ + +/** + * Renders a brushable horizontal bar chart for average {compound} values (e.g. terpenes, cannabinoids). + * @param {string} containerSelector - e.g. '#terpeneChart' or '#cannabinoidChart' + * @param {Object} strainData - The strain data object with fields like avg_alpha_pinene, avg_cbd, etc. + * @param {string[]} compoundList - Array of compound names (e.g. ['cbc','cbg','cbn'] or the terpene list) + */ +export function renderResultsBarChart(containerSelector, strainData, compoundList) { + /////////////////////////////////////////////////////////////////////////// + ////////////////////// 1) Prepare your compound data /////////////////////// + /////////////////////////////////////////////////////////////////////////// + + // Build an array like: [ { key: 0, compound: 'alpha_pinene', value: 0.12 }, ... ] + // Filter out compounds that are NaN, null, undefined, or <= 0 + let chartData = []; + let counter = 0; + compoundList.forEach((compound) => { + const avgKey = 'avg_' + compound; // e.g. "avg_alpha_pinene" or "avg_cbd" + let val = strainData[avgKey]; + if (val && !isNaN(val) && val > 0) { + chartData.push({ + key: counter++, + compound: compound, + value: val + }); + } + }); + + // Sort descending by value + chartData.sort((a, b) => b.value - a.value); + + // If no positive data, display a message + if (chartData.length === 0) { + d3.select(containerSelector).html('

No data available.

'); + return; + } + + /////////////////////////////////////////////////////////////////////////// + ///////////////////// 2) Compute chart sizes & remove old ///////////////// + /////////////////////////////////////////////////////////////////////////// + + // Remove any old chart (if re-rendering) + d3.select(containerSelector).selectAll('*').remove(); + + const barHeight = 20; + const maxBars = 20; // how many bars visible at once before needing to scroll + const mainHeight = Math.min(chartData.length, maxBars) * barHeight; + const miniHeight = mainHeight; + + const mainMargin = { top: 10, right: 10, bottom: 30, left: 140 }, + miniMargin = { top: 10, right: 10, bottom: 30, left: 10 }; + + const mainWidth = 500 - mainMargin.left - mainMargin.right; + const miniWidth = 100 - miniMargin.left - miniMargin.right; + + const svgWidth = mainWidth + mainMargin.left + mainMargin.right + miniWidth + miniMargin.left + miniMargin.right; + const svgHeight = mainHeight + mainMargin.top + mainMargin.bottom; + + // For mousewheel zoom in the mini chart + const zoomer = d3.behavior.zoom().on("zoom", null); + + /////////////////////////////////////////////////////////////////////////// + /////////////////////// 3) Create the main SVG & groups /////////////////// + /////////////////////////////////////////////////////////////////////////// + + const svg = d3.select(containerSelector) + .append('svg') + .attr('width', svgWidth) + .attr('height', svgHeight) + .call(zoomer) + .on("wheel.zoom", scroll) + // Disable other default zoom behaviors + .on("mousedown.zoom", null) + .on("touchstart.zoom", null) + .on("touchmove.zoom", null) + .on("touchend.zoom", null); + + // Main group wrapper + clip + const mainGroupWrapper = svg.append("g") + .attr("class", "mainGroupWrapper") + .attr("transform", `translate(${mainMargin.left},${mainMargin.top})`); + + const mainGroup = mainGroupWrapper.append("g") + .attr("clip-path", `url(#clip-${containerSelector})`) + .style("clip-path", `url(#clip-${containerSelector})`) + .attr("class", "mainGroup"); + + // Mini group (on the right) + const miniGroup = svg.append("g") + .attr("class", "miniGroup") + .attr("transform", `translate(${mainMargin.left + mainWidth + mainMargin.right + miniMargin.left},${miniMargin.top})`); + + // Brush group + const brushGroup = svg.append("g") + .attr("class", "brushGroup") + .attr("transform", `translate(${mainMargin.left + mainWidth + mainMargin.right + miniMargin.left},${miniMargin.top})`); + + /////////////////////////////////////////////////////////////////////////// + ///////////////// 4) Create scales, axes, and color mapping /////////////// + /////////////////////////////////////////////////////////////////////////// + + // X scales + const mainXScale = d3.scale.linear().range([0, mainWidth]); + const miniXScale = d3.scale.linear().range([0, miniWidth]); + + // Y scales (ordinal for bars) + const mainYScale = d3.scale.ordinal() + .domain(chartData.map(d => d.compound)) + .rangeBands([0, mainHeight], 0.1, 0); + + const miniYScale = d3.scale.ordinal() + .domain(chartData.map(d => d.compound)) + .rangeBands([0, miniHeight], 0.1, 0); + + // Domain for X + mainXScale.domain([0, d3.max(chartData, d => d.value)]); + miniXScale.domain([0, d3.max(chartData, d => d.value)]); + + // For brushing: track pixel range of the Y dimension + const mainYZoom = d3.scale.linear() + .range([0, mainHeight]) + .domain([0, mainHeight]); + + // Axes + const mainXAxis = d3.svg.axis() + .scale(mainXScale) + .orient("bottom") + .ticks(5); + + // Y-axis labels + const mainYAxis = d3.svg.axis() + .scale(mainYScale) + .orient("left") + .tickSize(0) + .tickFormat(d => formatCompoundName(d)); + + // Append x-axis (unclipped) + mainGroupWrapper.append("g") + .attr("class", "x axis") + .attr("transform", `translate(0,${mainHeight})`) + .call(mainXAxis); + + // Append y-axis (clipped) + mainGroup.append("g") + .attr("class", "y axis") + .call(mainYAxis); + + // Make each label clickable: + mainGroup.selectAll(".y.axis .tick text") + .style("cursor", "pointer") + .on("click", function(d) { + const analyteId = compoundToAnalyteId(d); + window.location.href = `/analytes/${analyteId}`; + }); + + // Color scale for each compound + const colorScale = d3.scale.category20() + .domain(chartData.map(d => d.compound)); + + // Create a tooltip (once) for this chart + const tooltip = d3.select("body") + .append("div") + .attr("class", "bar-tooltip") // so you can style it with CSS + .style("position", "absolute") + .style("padding", "5px 8px") + .style("background", "rgba(0, 0, 0, 0.7)") + .style("color", "#fff") + .style("border-radius", "4px") + .style("font-size", "12px") + .style("pointer-events", "none") + .style("opacity", 0) // start hidden + .style("z-index", 9999); + + /////////////////////////////////////////////////////////////////////////// + ///////////////////// 5) Define the brush behavior //////////////////////// + /////////////////////////////////////////////////////////////////////////// + + // E.g., show ~1/3 of the data or at least 6 bars by default + const defaultExtentCount = Math.max(6, Math.round(chartData.length * 0.33)); + const brush = d3.svg.brush() + .y(miniYScale) + .extent([ + miniYScale(chartData[0].compound), + miniYScale( + chartData[defaultExtentCount] + ? chartData[defaultExtentCount].compound + : chartData[chartData.length - 1].compound + ) + ]) + .on("brush", brushmove); + + const gBrush = brushGroup.append("g") + .attr("class", "brush") + .call(brush); + + gBrush.selectAll(".resize") + .append("line") + .attr("x2", miniWidth); + + gBrush.selectAll(".resize") + .append("path") + .attr("d", d3.svg.symbol().type("triangle-up").size(20)) + .attr("transform", function(d, i) { + // Flip the triangle for bottom handle + return i + ? `translate(${miniWidth / 2},4) rotate(180)` + : `translate(${miniWidth / 2},-4) rotate(0)`; + }); + + gBrush.selectAll("rect") + .attr("width", miniWidth); + + // Re-center on background click + gBrush.select(".background") + .on("mousedown.brush", brushcenter) + .on("touchstart.brush", brushcenter); + + /////////////////////////////////////////////////////////////////////////// + ////////////////// 6) Clip path (so main bars don’t overflow) ///////////// + /////////////////////////////////////////////////////////////////////////// + + const defs = svg.append("defs"); + defs.append("clipPath") + .attr("id", `clip-${containerSelector}`) + .append("rect") + .attr("x", -mainMargin.left) + .attr("width", mainWidth + mainMargin.left) + .attr("height", mainHeight); + + + // A glow filter for the bar strokes + const filter = defs.append('filter') + .attr('id', 'bar-glow'); // you can use a dynamic ID if needed + + filter.append('feGaussianBlur') + .attr('stdDeviation', '2.5') + .attr('result', 'coloredBlur'); + + const feMerge = filter.append('feMerge'); + feMerge.append('feMergeNode').attr('in', 'coloredBlur'); + feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); + + /////////////////////////////////////////////////////////////////////////// + ///////////////////// 7) Render the “mini” bar chart ////////////////////// + /////////////////////////////////////////////////////////////////////////// + + const miniBars = miniGroup.selectAll(".mini-bar") + .data(chartData, d => d.key); + + miniBars.enter().append("rect") + .attr("class", "mini-bar") + .attr("x", 0) + .attr("y", d => miniYScale(d.compound)) + .attr("width", d => miniXScale(d.value)) + .attr("height", miniYScale.rangeBand()) + .style("fill", d => colorScale(d.compound)); + + miniBars.exit().remove(); + + // Start the brush + gBrush.call(brush.event); + + /////////////////////////////////////////////////////////////////////////// + ////////////////////////// 8) Main bars update ///////////////////////////// + /////////////////////////////////////////////////////////////////////////// + + function updateMainBars() { + const bars = mainGroup.selectAll(".bar").data(chartData, d => d.key); + + // ENTER + bars.enter().append("rect") + .attr("class", "bar") + .attr("x", 0) + .on("mouseover", function(d) { + tooltip + .style('opacity', 1) + // Format the content. + .html(` + ${d.compound}
+ Avg.: ${d.value.toFixed(2)}% + `); + }) + .on("mousemove", function(d) { + // Position the tooltip near the cursor. + tooltip + .style('left', (d3.event.pageX + 10) + 'px') + .style('top', (d3.event.pageY - 20) + 'px'); + }) + .on("mouseout", function() { + // Hide tooltip. + tooltip.style('opacity', 0); + }); + + // UPDATE + bars + .attr("y", d => mainYScale(d.compound)) + .attr("height", mainYScale.rangeBand()) + .attr("width", d => mainXScale(d.value)) + .style("fill", d => colorScale(d.compound)) + .style("fill-opacity", 0.2) + .style("stroke", d => colorScale(d.compound)) + .style("stroke-width", 2) + .style("filter", "url(#bar-glow)"); + + // EXIT + bars.exit().remove(); + } + + + /////////////////////////////////////////////////////////////////////////// + //////////////////////// 9) Brush & scroll functions ////////////////////// + /////////////////////////////////////////////////////////////////////////// + + function brushmove() { + const extent = brush.extent(); + + // Re-map mainYZoom to the brush’s selection + const originalRange = mainYZoom.range(); + mainYZoom.domain(extent); + + // Update mainYScale’s range + mainYScale.rangeBands( + [ mainYZoom(originalRange[0]), mainYZoom(originalRange[1]) ], + 0.1, 0 + ); + + // Redraw y-axis + mainGroup.select(".y.axis").call(mainYAxis); + + // Determine which mini bars are within the brush’s extent + const selectedCompounds = miniYScale.domain().filter(d => { + const barTop = miniYScale(d); + const barBottom = barTop + miniYScale.rangeBand(); + return barTop >= extent[0] && barBottom <= extent[1]; + }); + + // Update color of mini bars outside the brush + miniGroup.selectAll(".mini-bar") + .style("fill", d => selectedCompounds.indexOf(d.compound) > -1 + ? colorScale(d.compound) + : "#ccc" + ); + + // Update main bars + updateMainBars(); + } + + function brushcenter() { + const target = d3.event.target, + extent = brush.extent(), + size = extent[1] - extent[0], + range = miniYScale.range(), + y0 = d3.min(range) + size / 2, + y1 = d3.max(range) + miniYScale.rangeBand() - size / 2, + center = Math.max(y0, Math.min(y1, d3.mouse(target)[1])); + + d3.event.stopPropagation(); + gBrush + .call(brush.extent([ center - size / 2, center + size / 2 ])) + .call(brush.event); + } + + function scroll() { + const extent = brush.extent(), + size = extent[1] - extent[0], + range = miniYScale.range(), + y0 = d3.min(range), + y1 = d3.max(range) + miniYScale.rangeBand(), + dy = d3.event.deltaY; + let topSection; + + if (extent[0] - dy < y0) { + topSection = y0; + } else if (extent[1] - dy > y1) { + topSection = y1 - size; + } else { + topSection = extent[0] - dy; + } + + d3.event.stopPropagation(); + d3.event.preventDefault(); + + gBrush + .call(brush.extent([topSection, topSection + size])) + .call(brush.event); + } + + /////////////////////////////////////////////////////////////////////////// + ///////////////////////// 10) Initialize main bars //////////////////////// + /////////////////////////////////////////////////////////////////////////// + + updateMainBars(); +} + + +/** + * For display in the label: underscores -> hyphens, + * alpha -> α, beta -> β, gamma -> γ, delta -> δ, etc. + */ +function formatCompoundName(compound) { + /** + * Format the compound name for display by: + * 1. Converting underscores to hyphens + * 2. Replacing English spellings of Greek letters with symbols + * 3. Capitalizing the first letter of each word (except Greek symbols) + */ + let display = compound.replace(/_/g, '-'); + const greekMap = { + alpha: 'α', + beta: 'β', + gamma: 'γ', + delta: 'δ' + }; + Object.keys(greekMap).forEach(eng => { + const re = new RegExp(eng, 'g'); + display = display.replace(re, greekMap[eng]); + }); + let words = display.split('-'); + words = words.map(word => { + if (Object.values(greekMap).includes(word)) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }); + return words.join('-'); +} + +/** + * For the URL: simply convert underscores -> hyphens (kebab-case). + * We do NOT replace alpha->α in the URL unless your routes specifically require that. + */ +function compoundToAnalyteId(compound) { + return compound.replace(/_/g, '-'); +} \ No newline at end of file diff --git a/website/assets/js/strains/resultsRadarChart.js b/website/assets/js/strains/resultsRadarChart.js new file mode 100644 index 0000000..ee3d106 --- /dev/null +++ b/website/assets/js/strains/resultsRadarChart.js @@ -0,0 +1,168 @@ +import { RadarChart } from './RadarChart.js'; + +/** + * Configuration object defining how to process and display compound data + * @typedef {Object} CompoundConfig + * @property {string} containerId - The HTML element ID for the chart + * @property {string[]} compoundList - List of compounds to check + * @property {number} [maxCompounds] - Maximum number of compounds to show (optional) + * @property {number} [maxValue] - Maximum value for the radar chart (optional) + * @property {Function} formatLabel - Function to format compound names + * @property {string[]} [colors] - Array of colors to use for the chart + */ + +/** + * Renders a radar chart showing the chemical compound profile of a strain + * @param {Object} strainData - The strain data object with avg_ fields + * @param {CompoundConfig} config - Configuration for the visualization + */ +export function renderCompoundRadarChart(strainData, config) { + // First process the data into the format needed for the radar chart + const processedData = processCompoundData(strainData, config); + + // If no compound data available, display a message + if (processedData[0].length === 0) { + d3.select(`#${config.containerId}`).html('

No compound data available.

'); + return; + } + + // Set up the basic dimensions with responsive sizing + const margin = {top: 100, right: 100, bottom: 100, left: 100}; + const width = Math.min(420, window.innerWidth - 10) - margin.left - margin.right; + const height = Math.min(width, window.innerHeight - margin.top - margin.bottom - 20); + + // Calculate maxValue if not provided in config + const maxValue = config.maxValue || calculateMaxValue(processedData[0]); + + // Configure the radar chart options + const radarChartOptions = { + w: width, + h: height, + margin: margin, + maxValue: maxValue, + levels: 5, + roundStrokes: true, + color: d3.scale.ordinal().range(config.colors || ['#2E8B57']), + opacityArea: 0.35, + strokeWidth: 3, + dotRadius: 4, + labelFactor: 1.3, // Increased from 1.2 to move labels further out + wrapWidth: 100, // Increased from 80 to allow more text + opacityCircles: 0.1, + legend: false, // No legend needed for single dataset + axisLabel: { // Added explicit label styling + fontSize: '11px', + fontFamily: 'Arial' + }, + }; + + // Call the RadarChart function + RadarChart(`#${config.containerId}`, processedData, radarChartOptions); +} + +/** + * Process the strain data into the format needed for the radar chart + * @param {Object} strainData - The raw strain data + * @param {CompoundConfig} config - Configuration object + * @returns {Array} - Array containing one array of compound values + */ +function processCompoundData(strainData, config) { + // Create array of {axis: "name", value: number} objects + let compoundData = config.compoundList + .map(compound => { + const avgKey = 'avg_' + compound; + let value = strainData[avgKey]; + + // Only include if we have a valid positive value + if (value && !isNaN(value) && value > 0) { + return { + compound: compound, + axis: config.formatLabel(compound), + value: value / 100 // Convert percentage to decimal + }; + } + return null; + }) + .filter(item => item !== null); + + // Sort by value descending + compoundData.sort((a, b) => b.value - a.value); + + // If maxCompounds is specified, limit to that number + if (config.maxCompounds && config.maxCompounds > 0) { + compoundData = compoundData.slice(0, config.maxCompounds); + } + + // Return as an array containing one dataset + return [compoundData]; +} + +/** + * Calculate the maximum value for the radar chart scale + * @param {Array} data - The processed compound data + * @returns {number} - The maximum value to use for the chart + */ +function calculateMaxValue(data) { + const maxDataValue = Math.max(...data.map(d => d.value)); + // Round up to the next 0.1 increment for a nice scale + return Math.ceil(maxDataValue * 10) / 10; +} + +// Example usage for cannabinoids: +export function renderCannabinoidRadarChart(strainData) { + renderCompoundRadarChart(strainData, { + containerId: 'cannabinoidChart', + compoundList: [ + 'cbc', 'cbca', 'cbcv', 'cbd', 'cbda', 'cbdv', 'cbdva', 'cbg', 'cbga', + 'cbl', 'cbla', 'cbn', 'cbna', 'cbt', 'delta_8_thc', 'delta_9_thc', + 'thcv', 'thcva' + ], + formatLabel: (name) => { + if (name === 'delta_8_thc') return 'Δ8-THC'; + if (name === 'delta_9_thc') return 'Δ9-THC'; + return name.toUpperCase().replace(/_/g, ' '); + }, + colors: ['#2E8B57'], // Sea Green + maxValue: 0.05, + }); +} + +// Example usage for terpenes: +export function renderTerpeneRadarChart(strainData) { + renderCompoundRadarChart(strainData, { + containerId: 'terpeneChart', + compoundList: [ + 'alpha_bisabolol', 'alpha_cedrene', 'alpha_humulene', 'alpha_ocimene', + 'alpha_phellandrene', 'alpha_pinene', 'alpha_terpinene', 'beta_caryophyllene', + 'beta_myrcene', 'beta_ocimene', 'beta_pinene', 'borneol', 'camphene', 'camphor', + 'caryophyllene_oxide', 'cedrol', 'cineole', 'citral', 'citronellol', 'd_limonene', + 'delta_3_carene', 'dihydrocarveol', 'eucalyptol', 'fenchol', 'fenchone', + 'gamma_terpinene', 'geraniol', 'geranyl_acetate', 'guaiol', 'hexahydrothymol', + 'isoborneol', 'isopulegol', 'linalool', 'menthol', 'nerol', 'nerolidol', + 'p_cymene', 'p_mentha_1_5_diene', 'phytol', 'pulegone', 'sabinene', 'terpineol', + 'terpinolene', 'alpha_terpineol', 'trans_beta_farnesene', 'trans_nerolidol', + 'valencene' + ], + maxCompounds: 6, // Limit to top 8 terpenes + formatLabel: (name) => { + // Special cases for common terpene prefixes + const formatted = name + .replace('alpha_', 'α-') + .replace('beta_', 'β-') + .replace('gamma_', 'γ-') + .replace('delta_', 'δ-') + .replace('trans_', 'trans-') + .replace(/_/g, ' ') + .toUpperCase(); + + // Break long names into multiple lines if needed + const parts = formatted.split(' '); + if (parts.length > 2) { + return parts[0] + '\n' + parts.slice(1).join(' '); + } + return formatted; + }, + colors: ['#4682B4'], // Steel Blue + maxValue: 0.02, + }); +} diff --git a/website/assets/js/strains/strains.js b/website/assets/js/strains/strains.js index 1e55ed8..1a7465d 100644 --- a/website/assets/js/strains/strains.js +++ b/website/assets/js/strains/strains.js @@ -1,10 +1,10 @@ /** * Strains JavaScript | Cannlytics Website - * Copyright (c) 2021-2022 Cannlytics + * Copyright (c) 2021-2025 Cannlytics * * Authors: Keegan Skeate * Created: 2/13/2024 - * Updated: 3/26/2024 + * Updated: 1/7/2025 * License: MIT License */ // import { @@ -17,8 +17,10 @@ import { createGrid, ModuleRegistry, ClientSideRowModelModule } from 'ag-grid-co import { Modal } from 'bootstrap'; import { getCollection, getDocument } from '../firebase.js'; import { formatDecimal, formatPercentage } from '../utils.js'; -import { renderCannabinoidChart } from './cannabinoidsChart.js'; -import { renderTerpeneChart } from './terpeneChart.js'; +// import { renderCannabinoidChart } from './cannabinoidsChart.js'; +// import { renderTerpeneChart } from './terpeneChart.js'; +import { renderCannabinoidRadarChart, renderTerpeneRadarChart } from './resultsRadarChart.js'; +import { renderResultsBarChart } from './resultsBarChart.js'; export const strainsJS = { @@ -224,11 +226,22 @@ export const strainsJS = { console.log('FIRESTORE STRAIN DATA:', data); } - // TODO: Render the cannabinoids figure. - renderCannabinoidChart(data); + // FIXME: Allow the user to select which chart to render. - // Render the terpenes figure. - renderTerpeneChart(data); + // Render a terpene bar chart. + renderResultsBarChart('#terpeneChart', data, TERPENE_LIST); + + // Render a cannabinoid bar chart. + renderResultsBarChart('#cannabinoidChart', data, CANNABINOID_LIST); + + // // Render a cannabinoid radar chart. + // renderCannabinoidRadarChart(data); + + // // Render a terpene radar chart. + // renderTerpeneRadarChart(data); + + // Setup chart toggles. + setupChartToggles(data); // Render strain data. document.getElementById('strain_name').textContent = data.strain_name; @@ -279,9 +292,6 @@ export const strainsJS = { // strain_type // first_date_tested - // Primary image. - // document.getElementById('image_url').src = data.image_url; - // Get a gallery of images for the strain. // Collection: strains/{strain-id}/strain_images const gallery = await getCollection(`strains/${strainId}/strain_images`, {}); @@ -306,7 +316,7 @@ export const strainsJS = { thumbnail.className = `thumbnail ${index === 0 ? 'active' : ''}`; const thumbImg = document.createElement('img'); - thumbImg.src = image.image_url; + thumbImg.src = image.image_download_url; thumbImg.alt = `Thumbnail ${index + 1}`; thumbnail.appendChild(thumbImg); @@ -421,7 +431,7 @@ export const strainsJS = { */ this.currentImageIndex = index; const mainImage = document.getElementById('mainImage'); - mainImage.src = images[index].image_url; + mainImage.src = images[index].image_download_url; document.querySelectorAll('.thumbnail').forEach((thumb, i) => { thumb.classList.toggle('active', i === index); }); @@ -433,7 +443,7 @@ export const strainsJS = { */ this.modalCurrentIndex = index; const modalImage = document.getElementById('modalMainImage'); - modalImage.src = images[index].image_url; + modalImage.src = images[index].image_download_url; document.querySelectorAll('.dot').forEach((dot, i) => { dot.classList.toggle('active', i === index); }); @@ -450,7 +460,7 @@ const createStrainCard = (strain) => { cardInnerElement.classList.add('card'); const imageElement = document.createElement('img'); - imageElement.src = strain.image_url || 'path/to/default/image.jpg'; + imageElement.src = strain.image_download_url || 'path/to/default/image.jpg'; imageElement.classList.add('card-img-top'); imageElement.alt = 'Strain'; @@ -473,3 +483,261 @@ const createStrainCard = (strain) => { return cardElement; }; + +function setupChartToggles(data) { + /** + * Setup the chart toggle buttons for the strain page. + */ + // References to each button (terpenes) + const tBarBtn = document.getElementById('terpeneBarBtn'); + const tRadarBtn = document.getElementById('terpeneRadarBtn'); + const tTableBtn = document.getElementById('terpeneTableBtn'); + + // References to each button (cannabinoids) + const cBarBtn = document.getElementById('cannabinoidBarBtn'); + const cRadarBtn = document.getElementById('cannabinoidRadarBtn'); + const cTableBtn = document.getElementById('cannabinoidTableBtn'); + + // Helper to toggle classes among 3 buttons + function setActiveButton(allButtons, activeBtn) { + allButtons.forEach(btn => { + btn.classList.remove('btn-primary', 'active'); + btn.classList.add('btn-outline-primary'); + }); + activeBtn.classList.remove('btn-outline-primary'); + activeBtn.classList.add('btn-primary', 'active'); + } + + // Terpene buttons: Bar, Radar, Table + const terpeneButtons = [ tBarBtn, tRadarBtn, tTableBtn ]; + + // Terpene BAR + tBarBtn.addEventListener('click', () => { + d3.select('#terpeneChart').selectAll('*').remove(); + renderResultsBarChart('#terpeneChart', data, TERPENE_LIST); + setActiveButton(terpeneButtons, tBarBtn); + }); + + // Terpene RADAR + tRadarBtn.addEventListener('click', () => { + d3.select('#terpeneChart').selectAll('*').remove(); + renderTerpeneRadarChart(data); + setActiveButton(terpeneButtons, tRadarBtn); + }); + + // Terpene TABLE + tTableBtn.addEventListener('click', () => { + d3.select('#terpeneChart').selectAll('*').remove(); + // The table-building function + renderResultsTable('#terpeneChart', data, TERPENE_LIST); + setActiveButton(terpeneButtons, tTableBtn); + }); + + // Cannabinoid buttons: Bar, Radar, Table + const cannabinoidButtons = [ cBarBtn, cRadarBtn, cTableBtn ]; + + // Cannabinoid BAR + cBarBtn.addEventListener('click', () => { + d3.select('#cannabinoidChart').selectAll('*').remove(); + renderResultsBarChart('#cannabinoidChart', data, CANNABINOID_LIST); + setActiveButton(cannabinoidButtons, cBarBtn); + }); + + // Cannabinoid RADAR + cRadarBtn.addEventListener('click', () => { + d3.select('#cannabinoidChart').selectAll('*').remove(); + renderCannabinoidRadarChart(data); + setActiveButton(cannabinoidButtons, cRadarBtn); + }); + + // Cannabinoid TABLE + cTableBtn.addEventListener('click', () => { + d3.select('#cannabinoidChart').selectAll('*').remove(); + renderResultsTable('#cannabinoidChart', data, CANNABINOID_LIST); + setActiveButton(cannabinoidButtons, cTableBtn); + }); +} + +function setActiveButton(allButtons, activeBtn) { + // allButtons is an array of button elements + // activeBtn is the one to highlight + + allButtons.forEach(btn => { + // Remove .active / .btn-primary from all + btn.classList.remove('btn-primary', 'active'); + btn.classList.add('btn-outline-primary'); + }); + + // Then add .active / .btn-primary to just the active one + activeBtn.classList.remove('btn-outline-primary'); + activeBtn.classList.add('btn-primary', 'active'); +}; + + +// List of cannabinoids. +const CANNABINOID_LIST = [ + 'cbc', 'cbca', 'cbcv', 'cbd', 'cbda', 'cbdv', 'cbdva', 'cbg', 'cbga', + 'cbl', 'cbla', 'cbn', 'cbna', 'cbt', 'delta_8_thc', 'delta_9_thc', + 'thcv', 'thcva', 'thca' +]; + +// List of terpenes. +const TERPENE_LIST = [ + 'alpha_bisabolol', 'alpha_cedrene', 'alpha_humulene', 'alpha_ocimene', + 'alpha_phellandrene', 'alpha_pinene', 'alpha_terpinene', 'beta_caryophyllene', + 'beta_myrcene', 'beta_ocimene', 'beta_pinene', 'borneol', 'camphene', 'camphor', + 'caryophyllene_oxide', 'cedrol', 'cineole', 'citral', 'citronellol', 'd_limonene', + 'delta_3_carene', 'dihydrocarveol', 'eucalyptol', 'fenchol', 'fenchone', + 'gamma_terpinene', 'geraniol', 'geranyl_acetate', 'guaiol', 'hexahydrothymol', + 'isoborneol', 'isopulegol', 'linalool', 'menthol', 'nerol', 'nerolidol', + 'p_cymene', 'p_mentha_1_5_diene', 'phytol', 'pulegone', 'sabinene', 'terpineol', + 'terpinolene', 'alpha_terpineol', 'trans_beta_farnesene', 'trans_nerolidol', 'valencene' +]; + +export function renderResultsTable(containerSelector, strainData, compoundList) { + // Remove old content + d3.select(containerSelector).selectAll('*').remove(); + + // Build an array of { compound, value } for the table + let tableData = []; + compoundList.forEach((compound) => { + const key = `avg_${compound}`; + let val = strainData[key]; + if (val && !isNaN(val) && val > 0) { + tableData.push({ + compound: compound, // raw + value: val + }); + } + }); + + if (tableData.length === 0) { + d3.select(containerSelector).html('

No data available.

'); + return; + } + + // Create the container + const table = d3.select(containerSelector) + .append('table') + .attr('class', 'table table-striped table-hover'); + + // Create thead & tr + const thead = table.append('thead').append('tr'); + + // We'll keep track of sorting states for each column + let sortState = { + compound: { ascending: true }, + value: { ascending: true } + }; + + ////////////////////////////// + // HEADERS + ////////////////////////////// + thead.append('th') + .attr('class', 'compound-col') + .text('Compound') + .style('cursor', 'pointer') + .on('click', () => { + // Toggle sort direction + sortState.compound.ascending = !sortState.compound.ascending; + // Sort tableData by 'compound' (raw snake_case) + tableData.sort((a, b) => { + const compA = a.compound.toLowerCase(); + const compB = b.compound.toLowerCase(); + if (compA < compB) return sortState.compound.ascending ? -1 : 1; + if (compA > compB) return sortState.compound.ascending ? 1 : -1; + return 0; + }); + // Re-render tbody + renderTableBody(); + }); + + thead.append('th') + .attr('class', 'value-col') + .text('Avg. (%)') + .style('cursor', 'pointer') + .on('click', () => { + // Toggle sort direction + sortState.value.ascending = !sortState.value.ascending; + // Sort tableData by 'value' + tableData.sort((a, b) => { + if (a.value < b.value) return sortState.value.ascending ? -1 : 1; + if (a.value > b.value) return sortState.value.ascending ? 1 : -1; + return 0; + }); + renderTableBody(); + }); + + // Create tbody + const tbody = table.append('tbody'); + + ////////////////////////////// + // BODY RENDER + ////////////////////////////// + function renderTableBody() { + // Clear existing rows + tbody.selectAll('tr').remove(); + + // Populate rows from sorted tableData + tableData.forEach(d => { + const row = tbody.append('tr'); + + // 1) COMPOUND column + // - Format for display with Greek/hyphens + // - Link to /analytes/ + const displayName = formatCompoundName(d.compound); + const analyteId = compoundToAnalyteId(d.compound); + + row.append('td') + .append('a') + .attr('href', `/analytes/${analyteId}`) + .attr('target', '_blank') // if you want it in a new tab + .text(displayName); + + // 2) VALUE column + row.append('td').text(d.value.toFixed(2)); + }); + } + + // 3) Initial render + renderTableBody(); +} + +// HELPER FUNCTIONS + +function formatCompoundName(compound) { + /** + * Format the compound name for display by: + * 1. Converting underscores to hyphens + * 2. Replacing English spellings of Greek letters with symbols + * 3. Capitalizing the first letter of each word (except Greek symbols) + */ + let display = compound.replace(/_/g, '-'); + const greekMap = { + alpha: 'α', + beta: 'β', + gamma: 'γ', + delta: 'δ' + }; + Object.keys(greekMap).forEach(eng => { + const re = new RegExp(eng, 'g'); + display = display.replace(re, greekMap[eng]); + }); + let words = display.split('-'); + words = words.map(word => { + if (Object.values(greekMap).includes(word)) { + return word; + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }); + return words.join('-'); +} + +function compoundToAnalyteId(compound) { + /** + * Convert underscores to hyphens for the link’s analyte ID (kebab-case). + * (We do NOT replace alpha/beta here, because the server likely expects + * “alpha-pinene” in the URL, not “α-pinene”.) + */ + return compound.replace(/_/g, '-'); +} diff --git a/website/templates/pages/strains/strain.html b/website/templates/pages/strains/strain.html index d0dd5d1..30d4087 100644 --- a/website/templates/pages/strains/strain.html +++ b/website/templates/pages/strains/strain.html @@ -158,9 +158,20 @@
Cannabinoid Profile
- -
- + + +
+ + + +
+
@@ -185,8 +196,27 @@
Terpene Profile
- -
+ + +
+ + + +
+
+ + + + +
+ +
Strain results
@@ -205,14 +235,6 @@
Similarly named strains
- -
- -
Strain results
- -
- -