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}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 SkeateNo data available.
'); + return; + } + + // Create the container