/**
* This file handles the scripting for the Reflectance Calculator.
* There are 3 parts to it:
* 1 - The sidebar filters
* 2 - The layers table
* 3 - The ZingChart chart
*
* Values from the sidebar and layers are converted into url search-params format (key/value - `?foo=bar&baz=biz`)
* and sent via a Fetch GET request to the server API endpoint where:
* 1 - It returns the data as series values for rendering in the chart (if the 'Plot' button pressed)
* 2 - It returns the data as a .txt file for download (if the 'Download' button pressed)
*
* When a user successfully adds a filter/layer combination that returns data, we store that config information
* in session storage. If the user refreshes the page or comes back to it, the filter/layers will start
* with that data instead of the default values.
*
* Please see the `init()` function to get started.
*/
// CONFIG
// -----------------------------
// Elements
let root = document.querySelector('reflectance-calculator');
let buttons = {
parent: document.querySelector('[refcalc-buttons]'),
// Buttons stored after load - 'Mobile' buttons cloned from matching base buttons in DOM
download: null,
downloadMobile: null,
plot: null,
plotMobile: null,
};
let chart = {
render: document.querySelector('refcalc-chart-render'),
plotting: document.querySelector('refcalc-layer-plotting'),
error: document.querySelector('refcalc-layer-error'),
}
let filters = {
angle: document.querySelector('[name="plot-angle"]'),
end: document.querySelector('[name="plot-end"]'),
polarization: document.querySelectorAll('[name="plot-polarization"]'),
spacing: document.querySelector('[name="plot-spacing"]'),
start: document.querySelector('[name="plot-start"]'),
type: document.querySelectorAll('[name="plot-type"]'),
};
let layer = {
parent: document.querySelector('refcalc-layer'),
medium: document.querySelector('[data-calc="medium"]'),
newLayers: document.querySelector('[data-calc="new-layers"]'),
thickness: document.querySelector('[data-calc="thickness"]'),
substrate: document.querySelector('[data-calc="substrate"]'),
};
let reflectanceIndex = {
dialog: document.querySelector('[data-calc="reflectance-index-dialog"]'),
input: document.querySelector('[data-calc="reflectance-index-input"]'),
no: document.querySelector('[data-calc="reflectance-index-no"]'),
yes: document.querySelector('[data-calc="reflectance-index-yes"]'),
};
// API Endpoints
let plotApiUrl = '/wp-json/reflectance-calculator/query';
let downloadApiUrl = '/wp-json/reflectance-calculator/file';
// Config
let materialRefractiveIndexLabel = 'Enter Refractive Index...';
let materialTypes = [materialRefractiveIndexLabel,'Acrylic','Ag','Air','Al','Al10Ga90As','Al20Ga80As','Al2O3','Al32Ga68As','Al42Ga58As','Al49Ga51As','Al59Ga41As','Al70Ga30As','Al80Ga20As','Al90Ga10As','AlAs','AlCu','AlN','AlSb','Au','BK7','CaF2','CdTe','Cellulose','Co','CoSi2','Cr','Cu','GaAs','GaN','GaP','GaSb','Ge','HfO2','InAs','InP','InSb','Insulator','ITO','KCl','MgF2','MgO','Mo','Nb','Ni','PbS','PbSe','PET','Polyacrylate','Polyethylene','Pt','Quartz','Rh','Si','Si3N4','SiO','SiO2','Styrene','Styrene','Ta','Ta2O5','Ti','TiN','TiO2','TiSi2','W','ZrO2',];
let materialOptions = materialTypes.map(type => `${type} `);
let thicknessUOMs = {
A: { multiplier: 10000, symbol: 'Å' },
nm: { multiplier: 1000, symbol: 'nm', default: true },
um: { multiplier: 1, symbol: 'µm' },
kA: { multiplier: 10, symbol: 'kÅ' },
uin: { multiplier: 39.3701, symbol: 'µin' },
mils: { multiplier: 0.0393701, symbol: 'mils' },
mm: { multiplier: 0.001, symbol: 'mm' },
'in': { multiplier: 0.0000393701, symbol: 'in' }
}
// Layer
let mediumStart = 'Air';
let substrateStart = 'Si';
let materialTypeDropdownIndex = 'data-index';
// Session Storage
let sessionStorageVariable = 'reflectance-calculator-filters';
// Chart
let chartId = 'reflectance-calculator-chart';
let chartColors = {
black: '#0c0c12',
darkGrey: '#b6b6b7',
lightGrey: '#e7e3e8',
lightPurple: '#fcf6fd',
purple: '#aa1dd5',
};
let chartRenderShared = {
width: '100%',
height: '100%',
output: 'canvas',
}
// INIT
// -----------------------------
async function init() {
// Add init attribute to assist JS-enabled styling
initForStyling();
// Init the starting values in the plot sidebar
initPlotFilters();
// Init the starting items for the layers table
initLayers();
// Clone 'Plot' and 'Download' buttons for mobile
initButtonClones();
// Handle 'Plot' Button Events
await initPlotButtons();
// Handle 'Download' Button Events
await initDownloadButtons();
// Init chart section
await initChart();
}
await init(); // Comment out if imported. See export below.
// ---
/**
* Add init attribute to assist JS-enabled styling (show the calc differently if JS enabled or not)
*/
function initForStyling() {
root?.setAttribute('init', '');
}
/**
* Init the starting values in the filters sidebar. If the user previously used a set of filters that returned data,
* those filter settings were saved in session storage and we'll use them if the page is refreshed or reloaded.
* If not, then we'll populate the filter items with a predetermined set of filter values.
* @returns none
*/
function initPlotFilters() {
let {angle, end, polarization, spacing, start, type} = filters;
// If url has `?key=value&key=value` filters use them (urlFilters)
// If not, but user previous had a successful chart render, use the filters from session storage (sessionFilters)
// Finally, if the user's first page load of the session, populate the filters/layers/chart with starting, default data
let urlFilters = getFiltersFromUrl();
let sessionFilters = getFiltersFromSessionStorage();
let userFilters = urlFilters && urlFilters.size ? urlFilters : sessionFilters;
// Use previous user choice if matching session data found
if (userFilters) {
// Input
angle.value = userFilters.get('angle');
end.value = userFilters.get('wmax');
spacing.value = userFilters.get('wstep');
start.value = userFilters.get('wmin');
// Radio
let matchingPolarizationRadio = getMatchingRadio(polarization, userFilters.get('pol'));
if (matchingPolarizationRadio) matchingPolarizationRadio.checked = true;
let matchingTypeRadio = getMatchingRadio(type, userFilters.get('sptype'));
if (matchingTypeRadio) matchingTypeRadio.checked = true;
}
// Defaults, no user session values found
else {
// Input
angle.value = 0;
end.value = 1000;
spacing.value = 1;
start.value = 200;
// Radio
polarization[0].checked = true;
type[0].checked = true;
}
}
/**
* Init the layer section's table items
*/
function initLayers() {
let {medium, thickness, substrate} = layer;
// Init 'Reflective Index' Overlay (dialog)
// ---
// This is shown when selecting 'Enter Refractive Index...' choice from a 'Material Type' layer dropdown
function handleLayerMaterialDropdownChange(e) {
let isRefractiveIndexOption = e.target.value === materialRefractiveIndexLabel;
if (!isRefractiveIndexOption || !reflectanceIndex.dialog) return;
// When showing the overlay, if there was a previously-added value, set it as the input's value before showing
// Otherwise, make sure the input value is cleared out.
let selectedOptionValue = Array.from( e.target.querySelectorAll('option') ).filter(option => option.selected)[0].value;
let setInputValue = selectedOptionValue > 0 ? selectedOptionValue : 0;
if (reflectanceIndex.input) reflectanceIndex.input.value = setInputValue;
// Add the 'data-index="X"' value to the dialog so it can find which dropdown launched it
reflectanceIndex.dialog.setAttribute(materialTypeDropdownIndex, e.target.getAttribute(materialTypeDropdownIndex));
// Show overlay to enter value
reflectanceIndex.dialog.showModal();
// Focus the input field
reflectanceIndex.input.focus();
}
// Add 'Cancel' overlay button click event
reflectanceIndex.no?.addEventListener('click', e => {
reflectanceIndex.dialog?.close()
});
// Add 'Ok' overlay button click event
reflectanceIndex.yes?.addEventListener('click', e => {
// Get the calling 'Material Type' select dropdown by reading the `data-index="X"` value from the dialog. The matching dropdown will have the same attribute/value
let callingDropdown = document.querySelector(`select[${materialTypeDropdownIndex}="${reflectanceIndex.dialog.getAttribute(materialTypeDropdownIndex)}"]`);
// Add the value from the overlay's input as a new option (selected) in the calling
callingDropdown.insertAdjacentHTML('beforeend', `${reflectanceIndex.input.value} `);
// Close the dialog
reflectanceIndex.dialog.close();
});
// Add options to dropdowns and set starting value
// ---
// Thickness
let urlFilters = getFiltersFromUrl();
let units = urlFilters?.get('units'); // Get stored units value (defaults to 'nm')
// Set 'active' dropdown option either if it matches stored units value or it matches the default type set in the `thicknessUOMs` reference array ('nm' on launch)
let thicknessOptions = Object.keys(thicknessUOMs).map(key => `${thicknessUOMs[key].symbol} `);
thickness.insertAdjacentHTML('beforeend', thicknessOptions.join('')); // Add options to dropdown
thickness.addEventListener('input', e => { // On change, update any added rows' input units display value (ex: 'nm' -> 'mils')
let targetInputs = layer.newLayers.querySelectorAll('tr input');
targetInputs?.forEach(input => input.nextElementSibling ? input.nextElementSibling.innerHTML = e.target.value : input);
});
// Medium
medium.insertAdjacentHTML('beforeend', materialOptions.join(''));
let mediumStartIndex = materialTypes.findIndex(type => type === mediumStart);
mediumStartIndex = mediumStartIndex > -1 ? mediumStartIndex : 0;
medium.querySelectorAll('option')[mediumStartIndex].selected = true;
medium.addEventListener('change', handleLayerMaterialDropdownChange);
// Substrate
substrate.insertAdjacentHTML('beforeend', materialOptions.join(''));
let substrateStartIndex = materialTypes.findIndex(type => type === substrateStart);
substrateStartIndex = substrateStartIndex > -1 ? substrateStartIndex : 0;
substrate.querySelectorAll('option')[substrateStartIndex].selected = true;
substrate.addEventListener('change', handleLayerMaterialDropdownChange);
// Store DOM elements
let rowCloneRef = document.querySelector('[data-calc="row-clone"]');
let thicknessSelect = document.querySelector('[data-calc="thickness"]');
// Add 'Add' button to 'Medium' row
// ---
let mediumGroup = document.querySelector('[data-calc="row-clone"] field-group');
if (mediumGroup) {
mediumGroup.insertAdjacentHTML('beforeend', addButtonMarkup());
let mediumAddButton = mediumGroup.querySelector('button');
mediumAddButton?.addEventListener('click', e => addLayerRow({ event: e, insertTarget: layer.newLayers, insertLocation: 'afterbegin' }));
}
// Add one 'user-added' layer row by default
// If pulling in previous user options from session storage, set all layers found instead
// ---
let userFilters = getFiltersFromUrl() || getFiltersFromSessionStorage();
let keys = [];
let values = [];
let defaultFilterLayers = new URLSearchParams( getFormattedCurrentFilters() ); // fyi, `getFormattedCurrentFilters()` returns `&key=value&key=value` string
let numberOfAddedDefaultEditableLayers = 1;
// Note: At launch, `defaultFilterLayers.size` was 11 (6 sidebar filters, 1 thickness dropdown, and 4 for 'Medium' and 'Substrate' layers (2 entries per layer - `mat[]` (material dropdown) and `d[]` (thickness input) ))
// Then we add 1 additional layer by default which represents a 'user' filter (has 'add' button), so final size is 13
// We use that default size to check against the size of the param string from session storage.
// If the new size is larger, we know there are user-added filters to add instead of adding the 1 default layer.
// ---
// In the future, if there are additional 'user' layers added by default you'll need to increment `numberOfAddeDefaultdEditableLayers` to accommodate
let baseFilterLayerSize = defaultFilterLayers.size + ( numberOfAddedDefaultEditableLayers * 2 );
// Add 1 default layer if no matching user choices from session storage (or there was only one)
if (!userFilters.size || userFilters.size <= baseFilterLayerSize) {
let startingValue = 'SiO2';
let startingThickness = 250;
if (userFilters && Array.from( userFilters.values() )[8]) {
startingValue = Array.from( userFilters.values() )[8];
startingThickness = Array.from( userFilters.values() )[9];
}
addLayerRow({ event: null, insertTarget: layer.newLayers, insertLocation: 'afterbegin', startType: startingValue, startThickness: startingThickness });
}
// Otherwise, add back all previous user layer rows (in the order they were)
else {
userFilters?.forEach((value,key) => {
if (key === 'mat[]') keys.push(value);
if (key === 'd[]') values.push(value);
});
// Since non-user added layers have the same key names as those added by users
// We have to remove them first so we don't double add them to the DOM ('Medium' and 'Substrate' rows)
// ---
// Remove the first key/value (this is the 'Medium' dropdown)
keys.shift();
values.shift();
// Remove the last key/value (this is the 'Substrate' dropdown)
keys.pop();
values.pop();
// Reverse arrays so they are rendered on screen in the order they were
keys.reverse();
values.reverse();
// For each remaining key/value pair, create a new row in the layers table
keys?.forEach((key,index) => {
addLayerRow({ event: null, insertTarget: layer.newLayers, insertLocation: 'afterbegin', startType: key, startThickness: values[index] });
});
}
// Again, we ported the server endpoint API as is and thus didn't update its behavior,
// so we have to adhere to its expected param structure :<
// Events (add button, close button, enter refractive index)
// ---
// 'Add' button click event (delegated to parent since rows are add/removed after load)
layer.newLayers?.addEventListener('click', handleLayerRowAddButton);
function handleLayerRowAddButton(e) {
let isAddButton = e.target.getAttribute('data-calc') === 'add';
let rowParent = e.target.closest('[data-calc="row-clone"]');
if (isAddButton) addLayerRow({ event: e, insertTarget: rowParent, insertLocation: 'afterend' });
}
// 'Close' buttons (delegated to parent since rows are add/removed after load)
layer.newLayers?.addEventListener('click', handleCloseButton);
function handleCloseButton(e) {
let isCloseButton = e.target.getAttribute('data-calc') === 'close';
let rowParent = e.target.closest('[data-calc="row-clone"]');
if (isCloseButton) {
rowParent.setAttribute('data-to-remove', '');
let layerParent = rowParent.closest('[data-calc="new-layers"]');
let toRemoveRow = layerParent.querySelector('[data-to-remove]');
if (toRemoveRow) {
let removeRowMaterialSelect = toRemoveRow.querySelector('select');
// Remove row's 'Material' dropdown event to avoid memory leaks
if (removeRowMaterialSelect) removeRowMaterialSelect.removeEventListener('change', handleLayerMaterialDropdownChange);
// Remove the row
toRemoveRow.remove();
}
let rows = layerParent.querySelectorAll('tr');
rows?.forEach((row, index) => setSequentialLayerNumber(row, index)); // Renumber the 'Layer Number' column
}
}
// Adds row into ` ` at designated points
// For example, `insertTarget` might be the layers parent and `insertLocation` might be `afterbegin`
// to add the row as the first child row...or insert as the last row.
// Or you might want to insert a row directly before or after another child row.
// Ex: addLayerRow(e, layer.newLayers, 'afterbegin') -> Add new row as first child
// Ex: addLayerRow(e, layer.newLayers['targeted child row index], 'afterend') -> Add new row after the targeted child row
function addLayerRow({ event, insertTarget, insertLocation, startType = materialTypes[1], startThickness = 0 }) {
if (event && event.preventDefault) event.preventDefault();
let rowClone = rowCloneRef.cloneNode(true);
let indexCell = rowClone.querySelector('td:nth-of-type(1)');
let materialCell = rowClone.querySelector('td:nth-of-type(2)');
let thicknessCell = rowClone.querySelector('td:nth-of-type(3)');
let closeSvg = ` `;
// If `startType` is a custom Refracted Index value, it won't be in our default lookup seed array, `materialTypes`.
// We need to manually add it as an `` to the dropdown and set it as the selected option.
// Without this, it won't find the starting value in the reference array and will pick a default option from it instead.
// Then when the starting chart renders, it will read this dropdown value when it goes to update the session storage variable
// and fetch the data for the chart render. This will result in the chart rendering data from the wrong set of filter values,
// and the params shown in the url bar won't match the filters in the layer rows, etc.
let materialOptionsStartTypeIndex = materialOptions.findIndex(option => option.includes(startType));
if (materialOptionsStartTypeIndex < 0) materialOptions.push(` ${startType} `);
// ---
let materialDropdownOptionsWithStartingOption = materialOptions.map(option => option.includes(startType) ? option.replace('', ' ') : option);
indexCell.innerHTML = ` ${addButtonMarkup()} `;
materialCell.innerHTML = `${ materialDropdownOptionsWithStartingOption.join('') } `;
thicknessCell.innerHTML = `${thicknessSelect && thicknessSelect.value || 'nm'} ${closeSvg} `;
insertTarget.insertAdjacentElement(insertLocation, rowClone);
// Add 'Material' dropdown change event (will need to remove this listener when row is removed to avoid memory leak)
let materialSelect = materialCell.querySelector('select');
if (materialSelect) materialSelect.addEventListener('change', handleLayerMaterialDropdownChange);
// Renumber the 'Layer Number' column
let renumberRefEl = insertTarget.getAttribute('data-calc') === 'new-layers' ? insertTarget : insertTarget.closest('[data-calc="new-layers"]');
let rows = renumberRefEl.querySelectorAll('tr');
rows?.forEach((row, index) => setSequentialLayerNumber(row, index));
}
// 'Add' buttons added in more than one location so we want to keep button markup consistent
function addButtonMarkup() {
return `Add `;
}
// Set sequential number to target element
function setSequentialLayerNumber(el, index) {
if (!el || index < 0) return;
let layerNumber = el.querySelector('span')
let materialTypeDropdown = el.querySelector('select')
layerNumber.textContent = index + 1; // Update 'Layer Number' column text
materialTypeDropdown.setAttribute(materialTypeDropdownIndex, index+2); // Update '` attribute value
return el;
}
}
/**
* Clone 'Plot' and 'Download' buttons for mobile
*/
function initButtonClones() {
if (!layer.parent) return;
let buttonsClone = buttons.parent.cloneNode(true);
buttonsClone.removeAttribute('refcalc-buttons');
buttonsClone.setAttribute('mobile', '');
// Add mobile buttons to the DOM (after layers table)
layer.parent.insertAdjacentHTML('afterend', buttonsClone.outerHTML);
// Store all 4 buttons for later use (plot, plotMobile, download, downloadMobile)
// ---
// Store starting 2 buttons in DOM
buttons.download = document.querySelector('[name="download"]');
buttons.plot = document.querySelector('[name="plot"]');
// Store newly-cloned mobile variants
buttons.downloadMobile = document.querySelector('field-buttons[mobile] [name="download"]');
buttons.downloadMobile.removeAttribute('name');
buttons.plotMobile = document.querySelector('field-buttons[mobile] [name="plot"]');
buttons.plotMobile.removeAttribute('name');
}
/**
* Init the Plot Button
*/
async function initPlotButtons() {
// Plot button click
buttons.plot?.addEventListener('click', await handlePlotClick);
buttons.plotMobile?.addEventListener('click', await handlePlotClick);
async function handlePlotClick(e) {
e.preventDefault();
// Show 'plotting points' message over the chart render area
showPlottingPointsMessage();
// Render the chart with the data from the current filter/layer parameters
await updateChart();
// Add query param string to url for sharing/recreating the chart
let formDataSerialized = getFormattedCurrentFilters(); // key=value&key=value string for passing to endpoint
history.replaceState([], '', `${location.href.split('?')[0]}?${formDataSerialized}`);
// Hide 'plotting points' message over the chart render area
hidePlottingPointsMessage();
}
}
/**
* Init the Download Button
*/
async function initDownloadButtons() {
// Download button click
buttons.download?.addEventListener('click', await handleDownloadClick);
buttons.downloadMobile?.addEventListener('click', await handleDownloadClick);
async function handleDownloadClick(e) {
e.preventDefault();
// Show 'plotting points' message over the chart render area
showPlottingPointsMessage();
// Get the download .txt file
await getDownload();
// Hide 'plotting points' message over the chart render area
hidePlottingPointsMessage();
}
}
/**
* Init the ZingChart chart
* Note: Should come after `initPlotFilters()` if you want to lookup
* and use previously-stored user sidebar/layer choices (session storage).
* If not, it will always load a default set of filter choices.
*/
async function initChart() {
// some flags for extra performance
zingchart.DEV.SKIPPROGRESS = 1;
zingchart.DEV.SORTTOKENS = 0;
zingchart.DEV.PLOTSTATS = 0;
zingchart.DEV.RESOURCES = 0;
zingchart.DEV.KEEPSOURCE = 0;
zingchart.DEV.DELAYEDTRACKERS = 1;
// Hide plotting points message container on load
// ---
if (chart.plotting) chart.plotting.setAttribute('hidden', '');
// Hide error message container on load
// ---
if (chart.error) chart.error.setAttribute('hidden', '');
// Render the chart with the data from the current filter/layer parameters
await updateChart();
}
// PROCESS FUNCTIONS
// -----------------------------
/**
* Render the chart with the data from the current filter/layer parameters.
* Note: At launch, this was called immediately on page load (to show a starting chart)
* and each time the 'Plot' button is clicked.
*/
async function updateChart() {
// Convert current sidebar and layer filter options into query param string
let formDataSerialized = getFormattedCurrentFilters(); // key=value&key=value string for passing to endpoint
// Fetch plot data from server ('plot' api endpoint)
let plotData = await getPlotData(formDataSerialized);
// Render chart
renderChart(plotData);
}
/**
* Use a Fetch GET request to pass the filter/layers values as query param string to API endpoint
* @returns {Object} - Object has `error`, `series`, and `spectrum_type` properties
*/
async function getPlotData(paramString) {
// let fetchPlotData = await fetch(`${plotApiUrl}?wmin=200&wmax=1000&wstep=1&angle=0&pol=s&units=nm&mat[]=Air&d[]=0&mat[]=SiO2&d[]=250&mat[]=Si&d[]=0&sptype=r`);
let fetchPlotData = await fetch(`${plotApiUrl}?${paramString}`);
let plotData = await fetchPlotData.json();
if (plotData.error) {
chart.error.innerHTML = plotData.error;
chart.error.removeAttribute('hidden');
}
else {
chart.error.setAttribute('hidden', '');
setFiltersInSessionStorage(paramString);
}
return plotData;
}
/**
* Render the main ZingChart chart
* @param {Ojbect} data - The data object returned from the API endpoint. It contains the series value array.
*/
function renderChart(data) {
if (!zingchart || !chart.render) return;
// Format min (plot start) and max (plot end) values and the plot step value
let minPlotFormatted = '100';
let maxPlotFormatted = '10000';
let plotStep = '100';
// ---
if (!data.error) {
let minPlotValue = data.series[0][0]; // 'plot start' input value
let maxPlotValue = data.series[data.series.length - 1][0]; // 'plot end' input value
plotStep = maxPlotValue - minPlotValue >= 150 ? 100 : Math.floor((maxPlotValue - minPlotValue ) / 10);
if (plotStep === 0) plotStep = 1;
else if (plotStep >= 3 && plotStep < 10) plotStep = 5;
else if (plotStep > 10 && plotStep < 100) plotStep = 10;
// ---
minPlotFormatted = getNearest10Xvalue(minPlotValue, plotStep);
maxPlotFormatted = getNearest10Xvalue(maxPlotValue, plotStep, 'max');
}
// console.log('minPlotFormatted', minPlotFormatted, 'maxPlotFormatted', maxPlotFormatted, 'plotStep', plotStep);
// Chart config (including data values)
let chartConfig = {
type: 'line',
gui: {
// Right-click context menu
// Note: Currently the whole menu disabled via `zingchart.bind(chartId, 'contextmenu', ...)` below.
// Using `gui: { behaviors: [] }` only lets you disable individual menu items
// behaviors: [],
},
'scale-x': {
// format: '%v',
minValue: minPlotFormatted, // Min Value
maxValue: maxPlotFormatted, // Max Value
step: plotStep, // Step Value
guide: {
lineColor: chartColors.lightGrey,
lineStyle: 'solid',
lineWidth: 1,
visible: true,
},
item: {
color: chartColors.black,
},
label: { // Title below x-axis labels
text: 'Wavelength (nm)',
fontColor: chartColors.black,
fontFamily: 'OpenSans',
fontSize: 16,
fontStyle: 'normal',
fontWeight: 'normal',
padding: '15px 0px 2px 0px',
},
// step: 1,
lineColor: 'transparent',
tick: {
visible: false,
},
},
'scale-y': {
guide: {
lineColor: chartColors.lightGrey,
lineStyle: 'solid',
lineWidth: 1,
visible: true,
},
// scale label with unicode character
label: {
text: data.spectrum_type,
fontColor: chartColors.black,
fontFamily: 'OpenSans',
fontSize: 16,
fontStyle: 'normal',
fontWeight: 'normal',
padding: '0px 0px 15px 0px',
},
lineColor: 'transparent',
// enable abbreviated units
short: true,
},
// set margins for labels around chart
plotarea: {
adjustLayout: true,
backgroundColor: chartColors.lightPurple,
borderColor: chartColors.darkGrey,
borderStyle: 'solid',
borderWidth: 1,
},
plot: {
aspect: 'spline', // Curved line
monotone: false,
'line-color': chartColors.purple,
marker: {
visible: false,
},
hoverMarker: {
backgroundColor: chartColors.black,
type: 'circle',
size: 5,
visible: true,
},
tooltip: {
text: `${data.spectrum_type.charAt(0)}: %data-formatted-type λ: %kt.00 nm`,
fontColor: 'black',
backgroundColor: 'white',
borderColor: '#ebebeb',
borderRadius: '7px',
borderWidth: 1,
padding: '7%',
shadow: false,
},
// Format y-axis values in tooltip to only display a max of 3 decimals
'data-formatted-type': data.series.map(series => series[1].toFixed(3)),
// By default, plot tooltips don't always show if the density of points to chart width is too small,
// By setting a large number value, we effectively always force tooltips
maxTrackers: 99999,
},
series: [{
values: data.series,
}],
};
// Render the chart
zingchart.render({
...chartRenderShared,
id: chartId,
data: chartConfig,
// events : {
// complete : chartInfo => console.log('chartInfo', chartInfo),
// error : error => console.error('error', error),
// }
});
// Reload chart (subsequent renders not clearing out causing ZC errors without this)
zingchart.exec(chartId, 'reload');
// Disable the right-click context menu
// Note: There wasn't a 'disable all' feature when adding: `gui: { behaviors: [] }`.
// If you need some menu items, remove this and use `behaviors` instead
zingchart.bind(chartId, 'contextmenu', cm => false);
}
/**
*
*/
async function getDownload() {
// Convert current sidebar and layer filter options into query param string
let formDataSerialized = getFormattedCurrentFilters(); // key=value&key=value string for passing to endpoint
// Redirect to download api endpoint with params, which will force the `.txt` download
window.location = `${downloadApiUrl}?${formDataSerialized}`;
}
/**
* Convert all sidebar and layer filter options into query param string
* for use in Fetch GET request.
* @returns {String} - The formatted string (`?foo=bar&baz=biz`)
*/
function getFormattedCurrentFilters() {
let formattedFilterValues = '';
// SIDEBAR FORM FILTERS
// ---
// Get the values from the sidebar 'filters' form elements
// Convert the data into the format the endpoint wants (notably, convert key names)
// -- Fyi, we gave the form elements readable names (like `plot-spacing`) but the endpoint code was old, ported code
// -- with corresponding names like `wstep` and `sptype`, so we map them.
let filterFormKeyMap = {
'plot-angle' : 'angle',
'plot-end' : 'wmax',
'plot-polarization' : 'pol',
'plot-spacing' : 'wstep',
'plot-start' : 'wmin',
'plot-type' : 'sptype',
};
let filterForm = document.querySelector('refcalc-leadin form');
let filterFormData = new FormData(filterForm);
let filterFormParams = new URLSearchParams(filterFormData);
let formattedFilterValuesType;
Array.from( filterFormParams.entries() ).forEach((param,index) => {
let keyValue = `${filterFormKeyMap[param[0]]}=${param[1]}`;
if (param[0] === 'plot-type') formattedFilterValuesType = `&${keyValue}`; // Store 'type' for use later - unsure if key/value order in string important for endpoint
else formattedFilterValues += `${index === 1 ? '' : '&'}${keyValue}`;
});
// UNITS
// ---
// Get's the 'Thickness' value unit of the last layer entry (nm, in, mm, etc.) in the table,
// regardless of current value in the thickness dropdown
let layerRows = layer.newLayers.querySelectorAll('tr');
let lastRow = Array.from(layerRows).pop();
let units = !!lastRow ? lastRow.querySelector('[type="space-between"] span').textContent : 'nm';
formattedFilterValues += `&units=${units}`;
// FIXED LAYERS
// ---
// Serialize the 'Medium' and 'Substrate' values
formattedFilterValues += `&mat[]=${layer.medium.value}&d[]=0`;
let formattedFilterValuesSubstrate = `&mat[]=${layer.substrate.value}&d[]=0`; // Store for use later
// USER-ADDED LAYERS
// ---
// Serialize the user-added layers
layerRows?.forEach(row => formattedFilterValues += `&mat[]=${row.querySelector('select').value}&d[]=${row.querySelector('input').value}`);
// Add 'substrate' key/value string
formattedFilterValues += formattedFilterValuesSubstrate;
// Add 'type' key/value string
formattedFilterValues += formattedFilterValuesType;
// Return formatted string (`?foo=bar&baz=biz`)
return formattedFilterValues;
}
/**
* When clicking the 'Plot' or 'Download' buttons, store the current filter information in session storage
* so we can recreate the current chart setup on page reload, etc.
* @param {String} filters - The serialized, key/value query param string of current chart filter settings
*/
function setFiltersInSessionStorage(filters) {
if (filters) sessionStorage.setItem(sessionStorageVariable, filters);
}
function getFiltersFromSessionStorage() {
let filters = sessionStorage.getItem(sessionStorageVariable);
let newParams = new URLSearchParams(filters);
return newParams.toString() !== 'null=' ? new URLSearchParams(filters) : false;
}
function getFiltersFromUrl() {
return new URLSearchParams(location.search);
}
// HELPER FUNCTIONS
// -----------------------------
/**
* Get the matching radio, by value, from a group of radios
* @param {*} matchValue
*/
function getMatchingRadio(radios, matchValue) {
return Array.from(radios).filter(r => r.value === matchValue)[0];
}
/**
* Returns the next number from the given number that is a multiple of 100 (or 10 if starting range less-than 100)
* If the min and max are less-than 100 away from each other, the axis step changes to multiples of 10 and the max plot axis value is the next-highest in the 10s column (not 100s)
* @param {String} value - The exact value to find the nearest 100 value
* @param {String} plotStep - Whether the min/max range differs by 10s or 100s
* @param {String} [type] - Optional. Whether to find the nearest 100 value above or below the starting value (omit for 'min', add 'max' for 100 value after starting value)
* @example 205 to 1133 -> 200, 300, ..., 1200
* @example 205 to 288 -> 200, 210, ..., 290
* @note For sufficiently large ranges (like 200 and 7780), ZingChart will fit the axis to the chart width, so the axis #s may differ by larger values than 100. However, the values will still be in multiples of 100.
* @returns {Number}
*/
function getNearest10Xvalue(value, plotStep, type) {
if (typeof value !== 'String') value = value.toString();
// Max 'plot end' is 10000, so anything over 9900 just set as 10000
if (plotStep < 5) {
return value;
}
if (type === 'max' && Number(value) > 9900) {
return '10000';
}
// 100-segment x-axis
if (plotStep === 100) {
if (type === 'max') {
let onesValue = value.charAt(value.length - 1);
let tensValue = value.charAt(value.length - 2);
let hundredsValue = value.charAt(value.length - 3);
let thousandsValue = value.charAt(value.length - 4);
if (hundredsValue === 9) {
hundredsValue = 0;
thousandsValue = Number(thousandsValue) + 1;
}
else if (tensValue > 0 || onesValue > 0) {
hundredsValue = Number(hundredsValue) + 1;
}
let otherValue = value.substring(0, value.length - 4);
return `${otherValue}${thousandsValue}${hundredsValue}00`;
}
}
// 5-segment and 10-segment x-axis
else {
// 10-segment x-axis 'max'
if (type === 'max' && plotStep === 10) {
let tensValue = value.charAt(value.length - 1) > 0 ? Number(value.charAt(value.length - 2)) + 1 : value.charAt(value.length - 2);
let otherValue = value.substring(0, value.length - 2);
return `${otherValue}${tensValue}0`;
}
// 5-segment x-axis 'max'
else if (type === 'max' && plotStep === 5) {
let onesValue = Number(value.charAt(value.length - 1)) > 5 || Number(value.charAt(value.length - 1)) === 0 ? 0 : 5;
let tensValue = Number(value.charAt(value.length - 1)) > 5 ? Number(value.charAt(value.length - 2)) + 1 : value.charAt(value.length - 2);
let otherValue = value.substring(0, value.length - 2);
if (tensValue === 10) {
tensValue = 0;
otherValue = Number(otherValue) + 1;
}
return `${otherValue}${tensValue}${onesValue}`;
}
// Both 'min'
else {
let onesValue = value.charAt(value.length - 1) < 5 ? 0 : 5;
let otherValue = value.substring(0, value.length - 1);
return `${otherValue}${onesValue}`;
}
}
}
/**
* Show 'plotting points' message over the chart render area
*/
function showPlottingPointsMessage() {
if (chart.plotting) chart.plotting.removeAttribute('hidden');
}
/**
* Hide 'plotting points' message over the chart render area
*/
function hidePlottingPointsMessage() {
if (chart.plotting) chart.plotting.setAttribute('hidden', '');
}
// EXPORT
// -----------------------------
export { init };