Comparing version 1.0.3 to 1.0.4
{ | ||
"name": "olli", | ||
"version": "1.0.3", | ||
"version": "1.0.4", | ||
"description": "The core of Olli handling the creation of the Accessibility Tree and rendering the accessible output to the DOM", | ||
@@ -9,3 +9,5 @@ "main": "./dist/olli.js", | ||
"test": "echo \"Error: no test specified\" && exit 1", | ||
"build": "webpack" | ||
"start": "webpack --config webpack.dev.js", | ||
"build": "webpack --config webpack.prod.js", | ||
"deploy-docs": "cp ./dist/olli.js ../../docs/olli" | ||
}, | ||
@@ -31,4 +33,5 @@ "repository": { | ||
"css-loader": "^6.7.1", | ||
"style-loader": "^3.3.1" | ||
"style-loader": "^3.3.1", | ||
"webpack-merge": "^5.8.0" | ||
} | ||
} |
@@ -0,0 +0,0 @@ # Directory Structure |
@@ -1,8 +0,10 @@ | ||
import { OlliVisSpec } from "olli-adapters/src/Types" | ||
import { OlliVisSpec } from "./Types" | ||
import { renderTable } from "./Render/Table" | ||
import { Tree } from "./Render/TreeView/Tree" | ||
import { renderTree } from "./Render/TreeView" | ||
import { TreeLinks } from "./Render/TreeView/TreeLink" | ||
import { olliVisSpecToTree } from "./Structure" | ||
import { AccessibilityTreeNode } from "./Structure/Types" | ||
import { AccessibilityTree } from "./Structure/Types" | ||
export * from './Types'; | ||
/** | ||
@@ -12,4 +14,3 @@ * The configuration object outlining how an accessible visualization should be rendered based on a {@link OlliVisSpec}. | ||
type OlliConfigOptions = { | ||
renderType?: 'tree' | 'table', | ||
ariaLabel?: string | ||
renderType?: 'tree' | 'table' | ||
} | ||
@@ -22,3 +23,3 @@ | ||
export function olli(olliVisSpec: OlliVisSpec, config?: OlliConfigOptions): HTMLElement { | ||
let chartEncodingTree: AccessibilityTreeNode = olliVisSpecToTree(olliVisSpec); | ||
const tree: AccessibilityTree = olliVisSpecToTree(olliVisSpec); | ||
@@ -29,4 +30,3 @@ const htmlRendering: HTMLElement = document.createElement("div"); | ||
config = { | ||
renderType: config?.renderType ?? 'tree', | ||
ariaLabel: config?.ariaLabel ?? undefined | ||
renderType: config?.renderType || 'tree' | ||
} | ||
@@ -36,26 +36,19 @@ | ||
case ("table"): | ||
htmlRendering.appendChild(renderTable(chartEncodingTree)); | ||
htmlRendering.appendChild(renderTable(tree.root.selected, tree.fieldsUsed)); | ||
break; | ||
case ('tree'): | ||
default: | ||
const ul = renderTree(chartEncodingTree); | ||
const ul = renderTree(tree); | ||
htmlRendering.appendChild(ul); | ||
new TreeLinks(ul).init(); | ||
const t = new Tree(ul); | ||
t.init(); | ||
document.addEventListener('keypress', (e) => { | ||
if (e.key === 't') { | ||
t.setFocusToItem(t.rootTreeItem); | ||
} | ||
}) | ||
break; | ||
} | ||
if (config.ariaLabel) { | ||
htmlRendering.setAttribute("aria-label", config.ariaLabel); | ||
} | ||
document.addEventListener('keypress', (keyStroke) => { | ||
if (keyStroke.key.toLowerCase() === 't') { | ||
const treeview = document.getElementById('treeView'); | ||
if (treeview !== null) { | ||
(treeview as any).firstChild!.focus() | ||
} | ||
} | ||
}) | ||
return htmlRendering; | ||
} |
@@ -1,2 +0,3 @@ | ||
import { AccessibilityTreeNode } from "../../Structure/Types"; | ||
import { OlliDataset, OlliDatum } from "../../Types"; | ||
import { fmtValue } from "../../utils"; | ||
@@ -8,21 +9,26 @@ /** | ||
*/ | ||
export function renderTable(tree: AccessibilityTreeNode): HTMLElement { | ||
export function renderTable(data: OlliDataset, fieldsUsed: string[]): HTMLElement { | ||
const table = document.createElement("table"); | ||
const thead = document.createElement("thead"); | ||
const theadtr = document.createElement("tr"); | ||
fieldsUsed.forEach((field: string) => { | ||
const th = document.createElement("th"); | ||
th.setAttribute('scope', 'col'); | ||
th.innerText = field; | ||
theadtr.appendChild(th); | ||
}); | ||
thead.appendChild(theadtr); | ||
table.appendChild(thead); | ||
const tableBody = document.createElement("tbody"); | ||
const tableHeaders = document.createElement("tr"); | ||
tree.fieldsUsed.forEach((field: string) => { | ||
const header = document.createElement("th"); | ||
header.innerText = field; | ||
tableHeaders.appendChild(header); | ||
}) | ||
tableBody.appendChild(tableHeaders) | ||
tree.selected.forEach((data: any) => { | ||
data.forEach((data: OlliDatum) => { | ||
const dataRow = document.createElement("tr") | ||
tree.fieldsUsed.forEach((field: string) => { | ||
const tableData = document.createElement("td") | ||
tableData.innerText = data[field]; | ||
dataRow.appendChild(tableData); | ||
fieldsUsed.forEach((field: string) => { | ||
const td = document.createElement("td") | ||
td.innerText = fmtValue(data[field]); | ||
dataRow.appendChild(td); | ||
}) | ||
tableBody.appendChild(dataRow); | ||
@@ -33,2 +39,2 @@ }) | ||
return table | ||
} | ||
} |
@@ -1,2 +0,5 @@ | ||
import { AccessibilityTreeNode } from "../../Structure/Types"; | ||
// Adapted from: https://w3c.github.io/aria-practices/examples/treeview/treeview-1/treeview-1b.html | ||
import { AccessibilityTree, AccessibilityTreeNode } from "../../Structure/Types"; | ||
import { fmtValue } from "../../utils"; | ||
import "./TreeStyle.css"; | ||
@@ -6,70 +9,119 @@ | ||
* | ||
* @param tree A {@link AccessibilityTreeNode} to generate a navigable tree view from | ||
* @param node A {@link AccessibilityTreeNode} to generate a navigable tree view from | ||
* @returns An {@link HTMLElement} ARIA TreeView of the navigable tree view for a visualization | ||
*/ | ||
export function renderTree(tree: AccessibilityTreeNode): HTMLElement { | ||
const nodeToAppend: HTMLElement = document.createElement("li") | ||
nodeToAppend.setAttribute("role", "treeitem"); | ||
nodeToAppend.setAttribute("aria-expanded", "false"); | ||
export function renderTree(tree: AccessibilityTree): HTMLElement { | ||
const namespace = (Math.random() + 1).toString(36).substring(7); | ||
const nestedChildElements: HTMLElement = document.createElement("ul") | ||
const node = tree.root; | ||
const nodeDescription: HTMLElement = document.createElement("span"); | ||
nodeDescription.appendChild(document.createTextNode(tree.description)); | ||
const root = document.createElement('ul'); | ||
const labelId = `${namespace}-${node.type}-label`; | ||
const treeChildren: AccessibilityTreeNode[] = tree.children; | ||
const dataChildren: AccessibilityTreeNode[] = treeChildren.filter((child: AccessibilityTreeNode) => child.type === "data") | ||
if (dataChildren.length > 0) { | ||
const table: HTMLElement = document.createElement("table"); | ||
root.setAttribute('role', 'tree'); | ||
root.setAttribute('aria-labelledby', labelId); | ||
const tableBody = document.createElement("tbody"); | ||
const rowHeaders = document.createElement("tr"); | ||
tree.fieldsUsed.forEach((key: string) => { | ||
const header = document.createElement("th") | ||
header.setAttribute("class", "tableInformation"); | ||
header.innerText = key | ||
rowHeaders.appendChild(header); | ||
}) | ||
tableBody.appendChild(rowHeaders) | ||
// const childContainer = document.createElement('ul'); | ||
// childContainer.setAttribute('role', 'group'); | ||
dataChildren.forEach((dataPoint: AccessibilityTreeNode) => { | ||
const dataRow = document.createElement("tr") | ||
tree.fieldsUsed.forEach((key: string) => { | ||
const headerData = document.createElement("td") | ||
headerData.setAttribute("class", "tableInformation"); | ||
const value = dataPoint.selected[0][key]; | ||
if (!isNaN(value) && value % 1 != 0) { | ||
headerData.innerText = Number(value).toFixed(2); | ||
} else { | ||
headerData.innerText = dataPoint.selected[0][key] | ||
} | ||
dataRow.appendChild(headerData); | ||
}) | ||
tableBody.appendChild(dataRow) | ||
}) | ||
// childContainer.appendChild(_renderTree(node, namespace, 1, 1, 1)); | ||
table.appendChild(tableBody); | ||
// root.appendChild(childContainer); | ||
nestedChildElements.appendChild(table); | ||
// childContainer.querySelector('span')?.setAttribute('id', labelId); | ||
root.appendChild(_renderTree(node, namespace, 1, 1, 1)); | ||
root.querySelector('span')?.setAttribute('id', labelId); | ||
return root; | ||
function _renderTree(node: AccessibilityTreeNode, namespace: string, level: number, posinset: number, setsize: number): HTMLElement { | ||
const item = document.createElement('li'); | ||
item.setAttribute('role', 'treeitem'); | ||
item.setAttribute('aria-level', String(level)); | ||
item.setAttribute('aria-setsize', String(setsize)); | ||
item.setAttribute('aria-posinset', String(posinset)); | ||
item.setAttribute('aria-expanded', 'false'); | ||
item.setAttribute('data-nodetype', node.type); | ||
if (node.gridIndex) { | ||
item.setAttribute('data-i', String(node.gridIndex.i)); | ||
item.setAttribute('data-j', String(node.gridIndex.j)); | ||
} | ||
nodeToAppend.appendChild(nodeDescription); | ||
const label = document.createElement('span'); | ||
label.textContent = node.description; | ||
item.appendChild(label); | ||
if (treeChildren.length > 0) { | ||
treeChildren.filter((child: AccessibilityTreeNode) => child.type !== `data`).forEach((child: AccessibilityTreeNode) => { | ||
nestedChildElements.appendChild(renderTree(child)); | ||
if (node.children.length) { | ||
const dataChildren = node.children.filter(n => n.type === 'data'); | ||
const treeChildren = node.children.filter(n => n.type !== 'data'); | ||
const childContainer = document.createElement('ul'); | ||
childContainer.setAttribute('role', 'group'); | ||
if (dataChildren.length) { | ||
childContainer.appendChild(createDataTable(dataChildren, level + 1)); | ||
} | ||
else { | ||
treeChildren.forEach((n, index, array) => { | ||
childContainer.appendChild(_renderTree(n, namespace, level + 1, index + 1, array.length)); | ||
}) | ||
nodeToAppend.appendChild(nestedChildElements); | ||
} | ||
item.appendChild(childContainer); | ||
} | ||
const treeDom = document.createElement("ul"); | ||
treeDom.appendChild(nodeToAppend); | ||
return treeDom; | ||
} | ||
return item; | ||
} | ||
// function appendStyle() { | ||
// const style = document.createElement('style') | ||
// style.innerHTML = treeStyle; | ||
// document.head.appendChild(style) | ||
function createDataTable(dataNodes: AccessibilityTreeNode[], level: number) { | ||
const table = document.createElement("table"); | ||
table.setAttribute('aria-label', `Table with ${dataNodes.length} rows`); | ||
table.setAttribute('aria-level', String(level)); | ||
table.setAttribute('aria-posinset', '1'); | ||
table.setAttribute('aria-setsize', '1'); | ||
// } | ||
const thead = document.createElement("thead"); | ||
const theadtr = document.createElement("tr"); | ||
dataNodes[0].tableKeys?.forEach((key: string) => { | ||
const th = document.createElement("th"); | ||
th.setAttribute('scope', 'col'); | ||
th.innerText = key | ||
theadtr.appendChild(th); | ||
}); | ||
thead.appendChild(theadtr); | ||
table.appendChild(thead); | ||
// | ||
const tableBody = document.createElement("tbody"); | ||
dataNodes.forEach((node) => { | ||
const dataRow = document.createElement("tr") | ||
node.tableKeys?.forEach((key: string) => { | ||
const td = document.createElement("td") | ||
const value = fmtValue(node.selected[0][key]); | ||
td.innerText = value; | ||
dataRow.appendChild(td); | ||
}) | ||
tableBody.appendChild(dataRow); | ||
}); | ||
table.appendChild(tableBody); | ||
const item = document.createElement('li'); | ||
item.setAttribute('role', 'treeitem'); | ||
item.setAttribute('aria-level', String(level)); | ||
item.setAttribute('aria-setsize', '1'); | ||
item.setAttribute('aria-posinset', '1'); | ||
item.setAttribute('aria-expanded', 'false'); | ||
item.appendChild(table); | ||
return item; | ||
} | ||
} |
@@ -1,4 +0,9 @@ | ||
import { Guide, Chart, OlliVisSpec, Mark, FacetedChart } from "olli-adapters/src/Types"; | ||
import { AccessibilityTreeNode, NodeType } from "./Types"; | ||
import { Guide, Chart, OlliVisSpec, FacetedChart, Axis, Legend, OlliDatum, OlliDataset } from "../Types"; | ||
import { fmtValue } from "../utils"; | ||
import { AccessibilityTree, AccessibilityTreeNode, NodeType } from "./Types"; | ||
type EncodingFilterValue = string | [number | Date, number | Date]; | ||
type GridFilterValue = [EncodingFilterValue, EncodingFilterValue]; | ||
type FilterValue = EncodingFilterValue | GridFilterValue; | ||
/** | ||
@@ -9,207 +14,67 @@ * Constructs an {@link AccessibilityTreeNode} based off of a generalized visualization | ||
*/ | ||
export function olliVisSpecToTree(olliVisSpec: OlliVisSpec): AccessibilityTreeNode { | ||
let node: AccessibilityTreeNode; | ||
if (olliVisSpec.type === "facetedChart") { | ||
let facets: FacetedChart = olliVisSpec as FacetedChart | ||
facets.charts.forEach((chart: Chart, k: string) => { | ||
chart.data = chart.data.filter((val: any) => val[facets.facetedField] === k) | ||
const updateNestedData = ((g: Guide) => g.data = JSON.parse(JSON.stringify(chart.data))) | ||
chart.axes.forEach(updateNestedData) | ||
chart.legends.forEach(updateNestedData) | ||
}) | ||
node = informationToNode(olliVisSpec.description, null, olliVisSpec.data, "multiView", olliVisSpec); | ||
node.description += ` with ${node.children.length} nested charts` | ||
} else { | ||
const axesString: string = olliVisSpec.axes.length > 0 ? | ||
olliVisSpec.axes.length == 2 ? | ||
` ${olliVisSpec.axes.length} axes` : | ||
` ${olliVisSpec.axes[0].orient} axis` : | ||
''; | ||
const legendsString: string = olliVisSpec.legends.length === 1 ? ` and ${olliVisSpec.legends.length} legend` : '' | ||
node = informationToNode(olliVisSpec.description, null, olliVisSpec.data, "chart", olliVisSpec); | ||
node.description += ` with ${axesString} ${legendsString}` | ||
export function olliVisSpecToTree(olliVisSpec: OlliVisSpec): AccessibilityTree { | ||
const fieldsUsed = getFieldsUsedForChart(olliVisSpec); | ||
switch (olliVisSpec.type) { | ||
case "facetedChart": | ||
return { | ||
root: olliVisSpecToNode("multiView", olliVisSpec.data, null, olliVisSpec, fieldsUsed), | ||
fieldsUsed | ||
} | ||
case "chart": | ||
return { | ||
root: olliVisSpecToNode("chart", olliVisSpec.data, null, olliVisSpec, fieldsUsed), | ||
fieldsUsed | ||
} | ||
default: | ||
throw `olliVisSpec.type ${(olliVisSpec as any).type} not handled in olliVisSpecToTree`; | ||
} | ||
return node | ||
} | ||
/** | ||
* Generates children tree nodes for the given parent node. | ||
* @param parent The root faceted chart to be the parent of each nested chart | ||
* @param multiViewChart The {@link FacetedChart} of the abstracted visualization | ||
* @returns an array of {@link AccessibilityTreeNode} to be the given parent's children | ||
*/ | ||
function generateMultiViewChildren(parent: AccessibilityTreeNode, multiViewChart: FacetedChart): AccessibilityTreeNode[] { | ||
multiViewChart.type === "facetedChart" | ||
let charts: AccessibilityTreeNode[] = [] | ||
multiViewChart.charts.forEach((c: Chart, k: string, m: Map<any, Chart>) => { | ||
charts.push(informationToNode( | ||
`A facet titled ${k}, ${charts.length + 1} of ${m.size}`, | ||
parent, | ||
multiViewChart.data, | ||
"chart", | ||
c)) | ||
}) | ||
return charts; | ||
function getFieldsUsedForChart(olliVisSpec: OlliVisSpec): string[] { | ||
switch (olliVisSpec.type) { | ||
case "facetedChart": | ||
return [...new Set([olliVisSpec.facetedField, ...[...olliVisSpec.charts.values()].flatMap((chart: Chart) => getFieldsUsedForChart(chart))])]; | ||
case "chart": | ||
return [...new Set((olliVisSpec.axes as Guide[]).concat(olliVisSpec.legends).reduce((fields: string[], guide: Guide) => fields.concat(guide.field), []))]; | ||
default: | ||
throw `olliVisSpec.type ${(olliVisSpec as any).type} not handled in getFieldsUsedForChart`; | ||
} | ||
} | ||
/** | ||
* Recursively generates children nodes of a chart's structured elements for the provided parent | ||
* @param childrenNodes the array of children nodes to eventually return to the parent | ||
* @param parent The root chart to be the parent of each nested chart | ||
* @param axes The {@link Guide}s of axes to be transformed into {@link AccessibilityTreeNode}s | ||
* @param legends The {@link Guide}s of legends to be transformed into {@link AccessibilityTreeNode}s | ||
* @param grids The {@link Guide}s of axes with grid lines to be transformed into {@link AccessibilityTreeNode}s | ||
* @returns an array of {@link AccessibilityTreeNode} to be the given parent's children | ||
*/ | ||
function generateChartChildren(childrenNodes: AccessibilityTreeNode[], parent: AccessibilityTreeNode, | ||
axes: Guide[], legends: Guide[], grids: Guide[]): AccessibilityTreeNode[] { | ||
if (axes.length > 0) { | ||
const axis: Guide = axes.pop()!; | ||
const scaleStr: string = axis.scaleType ? `for a ${axis.scaleType} scale ` : ""; | ||
let axisField: string = Array.isArray(axis.field) ? axis.field[1] : (axis.field as string); | ||
let defaultRange: number | string = axis.data[0][axisField] | ||
// TODO: Re-used code from line 143. Make utility function and add try/catch since the data should not be undefined! | ||
if (defaultRange === undefined) { | ||
let updatedField = Object.keys(axis.data[0]).find((k: string) => k.includes(axisField) || axisField.includes(k)) | ||
if (updatedField) { | ||
axisField = updatedField | ||
defaultRange = axis.data[0][axisField]; | ||
const filterInterval = (selection: OlliDataset, field: string, lowerBound: number | Date, upperBound: number | Date): OlliDataset => { | ||
return selection.filter((datum: any) => { | ||
let value = datum[field]; | ||
if (value instanceof Date) { | ||
const lowerBoundStr = String(lowerBound); | ||
const upperBoundStr = String(upperBound); | ||
if (lowerBoundStr.length === 4 && upperBoundStr.length === 4) { | ||
value = value.getFullYear(); | ||
} | ||
} | ||
return value >= lowerBound && value < upperBound; | ||
}) | ||
} | ||
let minValue: number | string = axis.data.reduce((min: any, val: any) => { | ||
if (val[axisField] !== null && val[axisField] < min) return val[axisField] | ||
return min | ||
}, axis.data[0][axisField]) | ||
function axisValuesToIntervals(values: string[] | number[]): ([number, number] | [Date, Date])[] { | ||
let maxValue: number | string = axis.data.reduce((max: any, val: any) => { | ||
if (val[axisField] !== null && val[axisField] > max) return val[axisField] | ||
return max | ||
}, axis.data[0][axisField]) | ||
if (axisField.toLowerCase().includes("date")) { | ||
minValue = new Date(minValue).toLocaleString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); | ||
maxValue = new Date(maxValue).toLocaleString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); | ||
const ensureAxisValuesNumeric = (values: any[]): {values: number[], isDate?: boolean} => { | ||
const isStringArr = values.every(v => typeof v === 'string' || v instanceof String); | ||
if (isStringArr) { | ||
return { | ||
values: values.map(s => Number(s.replaceAll(',', ''))) | ||
}; | ||
} | ||
const description = `${axis.title} ${scaleStr}with values from ${minValue} to ${maxValue}`; | ||
childrenNodes.push(informationToNode(description, parent, axis.data, axis.title.includes("Y-Axis") ? "yAxis" : "xAxis", axis)); | ||
return generateChartChildren(childrenNodes, parent, axes, legends, grids); | ||
} else if (legends.length > 0) { | ||
const legend: Guide = legends.pop()!; | ||
const scaleType = legend.scaleType ? `for ${legend.scaleType} scale ` : ""; | ||
let node: AccessibilityTreeNode = informationToNode(legend.title, parent, legend.data, "legend", legend) | ||
node.description = `Legend titled '${node.description}' ${scaleType}with ${node.children.length} values`; | ||
childrenNodes.push(node); | ||
return generateChartChildren(childrenNodes, parent, axes, legends, grids); | ||
} else if (grids.length > 0 && grids.length === 2) { | ||
const grid: Guide[] = [grids.pop()!, grids.pop()!]; | ||
childrenNodes.push(informationToNode("Grid view of the data", parent, grid[0].data, "grid", grid)) | ||
return generateChartChildren(childrenNodes, parent, axes, legends, grids); | ||
} else { | ||
return childrenNodes; | ||
} | ||
} | ||
/** | ||
* Generates the incremental children for each structured element of a visualization | ||
* @param parent The structured element whose data is being incrmeented | ||
* @param field The data field used to compare idividual data points | ||
* @param values The groupings or increments of values for the structured element (ex: for axes these are the array of ticks) | ||
* @param data The array of data used in the visualization | ||
* @param markUsed {@link Mark} of the visualization | ||
* @returns an array of {@link AccessibilityTreeNode} to be the given parent's children | ||
*/ | ||
function generateStructuredNodeChildren(parent: AccessibilityTreeNode, field: string, values: string[] | number[], data: any[], markUsed: Mark): AccessibilityTreeNode[] { | ||
const lowerCaseDesc: string = parent.description.toLowerCase(); | ||
if (isStringArray(values) && !field.includes("date") || parent.type === "legend") { | ||
return values.map((grouping: any) => { | ||
return informationToNode(`${[[grouping]]}`, parent, data.filter((node: any) => node[field] === grouping), "filteredData", data.filter((node: any) => node[field] === grouping)) | ||
}) | ||
} else { | ||
const ticks: number[] = values as number[] | ||
const filterData = (lowerBound: number, upperBound: number): any[] => { | ||
return data.filter((val: any) => { | ||
if ((lowerCaseDesc.includes("date") || lowerCaseDesc.includes("temporal")) && upperBound.toString().length === 4) { | ||
const d = new Date(val[field]) | ||
return d.getFullYear() >= lowerBound && d.getFullYear() < upperBound; | ||
} else if (val[field] === undefined) { | ||
let updatedField = Object.keys(val).find((k: string) => k.includes(field) || field.includes(k)) | ||
if (updatedField) return val[updatedField] >= lowerBound && val[updatedField] < upperBound; | ||
} | ||
return val[field] >= lowerBound && val[field] < upperBound; | ||
}) | ||
const isDateArr = values.every(v => v instanceof Date); | ||
if (isDateArr) { | ||
return { | ||
values: values.map(d => d.getTime()), | ||
isDate: true | ||
}; | ||
} | ||
let valueIncrements: any[]; | ||
if (markUsed !== 'bar') { | ||
valueIncrements = ticks.reduce(getEncodingValueIncrements, []); | ||
} else { | ||
if (lowerCaseDesc.includes("date") || field.includes("date")) { | ||
valueIncrements = ticks.reduce(getEncodingValueIncrements, []); | ||
} else { | ||
valueIncrements = ticks.map((val: number) => [val, val]); | ||
} | ||
} | ||
return valueIncrements.map((range: number[]) => { | ||
let desc = `` | ||
if ((lowerCaseDesc.includes("date") || field.includes("date") || parent.description.includes("temporal")) && range[0].toString().length > 4) { | ||
range.forEach((val: number) => desc += `${new Date(val).toLocaleString("en-US", { year: 'numeric', month: 'short', day: 'numeric' })}, `) | ||
} else { | ||
desc = `${range},` | ||
} | ||
return informationToNode(desc, parent, filterData(range[0], range[1]), "filteredData", filterData(range[0], range[1])); | ||
}); | ||
return { | ||
values | ||
}; | ||
} | ||
} | ||
/** | ||
* Generates the incremental children for a pair of axes forming an explorable grid | ||
* @param parent The structured element whose data is being incrmeented | ||
* @param field The data fields used to compare idividual data points | ||
* @param firstValues Array of tick values for the first axis | ||
* @param secondValues Array of tick values for the second axis | ||
* @param data The array of data used in the visualization | ||
* @returns an array of {@link AccessibilityTreeNode} to be the given parent's children | ||
*/ | ||
function generateGridChildren(parent: AccessibilityTreeNode, fields: string[], firstValues: number[], secondValues: number[], data: any[]): AccessibilityTreeNode[] { | ||
let childNodes: AccessibilityTreeNode[] = [] | ||
const filterData = (xLowerBound: number | string, yLowerBound: number | string, xUpperBound?: number | string, yUpperBound?: number | string): any[] => { | ||
return data.filter((val: any) => { | ||
const inRange = (field: string, r1: number | string, r2?: number | string): boolean => { | ||
if (r2) { | ||
return val[field] >= r1 && val[field] < r2 | ||
} else { | ||
return val[field] === r1 | ||
} | ||
} | ||
return inRange(fields[1], xLowerBound, xUpperBound) && inRange(fields[0], yLowerBound, yUpperBound); | ||
}); | ||
} | ||
const yIncrements: number[][] | string[][] = firstValues.reduce(getEncodingValueIncrements, []); | ||
const xIncrements: number[][] | string[][] = secondValues.reduce(getEncodingValueIncrements, []); | ||
yIncrements.forEach((yIncrement: number[] | string[]) => { | ||
xIncrements.forEach((xIncrement: number[] | string[]) => { | ||
const filteredSelection: any[] = filterData(xIncrement[0], yIncrement[0], xIncrement[1], yIncrement[1]); | ||
childNodes.push(informationToNode(`${[yIncrement, xIncrement]}`, parent, filteredSelection, "filteredData", filteredSelection)); | ||
}) | ||
}) | ||
return childNodes; | ||
} | ||
function isStringArray(data: any[]): data is string[] { | ||
return data.every((pnt: string | number) => typeof pnt === "string") | ||
} | ||
function getEncodingValueIncrements(incrementArray: any[][], currentValue: any, index: number, array: number[] | string[]): any[][] { | ||
if (isStringArray(array)) { | ||
incrementArray.push([currentValue]) | ||
return incrementArray | ||
} else { | ||
const getEncodingValueIncrements = (incrementArray: [number, number][], currentValue: number, index: number, array: number[]): [number, number][] => { | ||
let bounds: [number, number] | ||
@@ -224,8 +89,3 @@ let reducedIndex = index - 1; | ||
const incrementDifference: number = currentValue - (array[index - 1] as number) | ||
let finalIncrement; | ||
if (currentValue instanceof Date) { | ||
finalIncrement = currentValue.getTime() + incrementDifference; | ||
} else { | ||
finalIncrement = currentValue + incrementDifference; | ||
} | ||
const finalIncrement = currentValue + incrementDifference; | ||
incrementArray.push([array[reducedIndex] as number, currentValue]) | ||
@@ -240,53 +100,12 @@ bounds = [currentValue, finalIncrement]; | ||
} | ||
} | ||
/** | ||
* Recursively generates a child node for each data point in the provided range | ||
* @param childrenNodes The array {@link AccessibilityTreeNode} to eventually return | ||
* @param filteredSelection The data points to transform into {@link AccessibilityTreeNode} nodes | ||
* @param parent The parent whose children are being generated | ||
* @returns | ||
*/ | ||
function generateFilteredDataChildren(childrenNodes: AccessibilityTreeNode[], filteredSelection: any[], parent: AccessibilityTreeNode): AccessibilityTreeNode[] { | ||
if (filteredSelection.length > 0) { | ||
// const dataPoint: any = filteredSelection.pop(); | ||
const dataPoint: any = filteredSelection.pop(); | ||
let objCopy: any = {}; | ||
Object.keys(dataPoint).forEach((key: string) => { | ||
if (key.toLowerCase().includes("date")) { | ||
objCopy[key] = new Date(dataPoint[key]).toLocaleString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); | ||
} else { | ||
objCopy[key] = dataPoint[key] | ||
} | ||
}) | ||
childrenNodes.push(informationToNode(nodeToDesc(dataPoint), parent, [objCopy], "data")) | ||
generateFilteredDataChildren(childrenNodes, filteredSelection, parent) | ||
const res = ensureAxisValuesNumeric(values); | ||
const increments = res.values.reduce(getEncodingValueIncrements, []); | ||
if (res.isDate) { | ||
return increments.map(value => [new Date(value[0]), new Date(value[1])]) | ||
} | ||
return childrenNodes | ||
return increments; | ||
} | ||
/** | ||
* Creates specific children nodes based on a provided {@link NodeType} | ||
* @param type The {@link NodeType} of the parent | ||
* @param parent The parent {@link AccessibilityTreeNode} whose children need to be generated | ||
* @param generationInformation A changing variable that assists in generating children nodes at all levels | ||
* @returns an array of {@link AccessibilityTreeNode} | ||
*/ | ||
function generateChildNodes(type: NodeType, parent: AccessibilityTreeNode, generationInformation: any): AccessibilityTreeNode[] { | ||
if (type === "multiView") { | ||
return generateMultiViewChildren(parent, generationInformation); | ||
} else if (type === "chart") { | ||
return generateChartChildren([], parent, generationInformation.axes, generationInformation.legends, generationInformation.gridNodes); | ||
} else if (type === "xAxis" || type === "yAxis" || type === "legend") { | ||
return generateStructuredNodeChildren(parent, generationInformation.field, generationInformation.values, generationInformation.data, generationInformation.markUsed); | ||
} else if (type === "filteredData") { | ||
return generateFilteredDataChildren([], generationInformation.map((val: any) => Object.assign({}, val)), parent); | ||
} else if (type === "grid") { | ||
return generateGridChildren(parent, [generationInformation[0].field, generationInformation[1].field], generationInformation[0].values, generationInformation[1].values, generationInformation[0].data) | ||
} else { | ||
return []; | ||
} | ||
} | ||
/** | ||
* Creates a {@link AccessibilityTreeNode} of the given parameters | ||
@@ -300,15 +119,181 @@ * @param desc The string that will be used when rendering this node | ||
*/ | ||
function informationToNode(desc: string, parent: AccessibilityTreeNode | null, selected: any[], type: NodeType, childrenInformation?: any): AccessibilityTreeNode { | ||
function olliVisSpecToNode(type: NodeType, selected: any[], parent: AccessibilityTreeNode | null, olliVisSpec: OlliVisSpec, fieldsUsed: string[], facetValue?: string, filterValue?: FilterValue, guide?: Guide, index?: number, length?: number, gridIndex?: {i: number, j: number}): AccessibilityTreeNode { | ||
let node: AccessibilityTreeNode = { | ||
description: desc, | ||
type: type, | ||
parent: parent, | ||
selected: selected, | ||
gridIndex, | ||
// | ||
description: type, | ||
children: [], | ||
selected: selected, | ||
type: type, | ||
fieldsUsed: parent !== null ? parent.fieldsUsed : childrenInformation.dataFieldsUsed | ||
} | ||
if (childrenInformation) node.children = generateChildNodes(type, node, childrenInformation); | ||
node.description = nodeToDesc(node); | ||
return node | ||
const facetedChart = olliVisSpec as FacetedChart; | ||
const chart = olliVisSpec as Chart; | ||
switch (type) { | ||
case "multiView": | ||
node.children = [...facetedChart.charts.entries()].map(([facetValue, chart]: [string, Chart], index, array) => { | ||
return olliVisSpecToNode( | ||
"chart", | ||
selected.filter((datum: any) => String(datum[facetedChart.facetedField]) === facetValue), | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
undefined, | ||
undefined, | ||
index, | ||
array.length | ||
); | ||
}); | ||
break; | ||
case "chart": | ||
// remove some axes depending on mark type | ||
const filteredAxes = chart.axes.filter(axis => { | ||
if (chart.mark === 'bar' && axis.type === 'continuous') { | ||
// don't show continuous axis for bar charts | ||
return false; | ||
} | ||
return true; | ||
}); | ||
node.children = [ | ||
...filteredAxes.map(axis => { | ||
return olliVisSpecToNode( | ||
axis.axisType === 'x' ? 'xAxis' : 'yAxis', | ||
selected, | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
undefined, | ||
axis); | ||
}), | ||
...chart.legends.map(legend => { | ||
return olliVisSpecToNode( | ||
'legend', | ||
selected, | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
undefined, | ||
legend); | ||
}), | ||
...(chart.mark === 'point' && filteredAxes.length === 2 && filteredAxes.every(axis => axis.type === 'continuous') ? [ | ||
olliVisSpecToNode('grid', selected, node, chart, fieldsUsed, facetValue) | ||
] : []) | ||
] | ||
break; | ||
case "xAxis": | ||
case "yAxis": | ||
const axis = guide as Axis; | ||
switch (axis.type) { | ||
case "discrete": | ||
node.children = axis.values.map(value => { | ||
return olliVisSpecToNode( | ||
'filteredData', | ||
selected.filter(d => String(d[axis.field]) === String(value)), | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
String(value), | ||
axis); | ||
}); | ||
break; | ||
case "continuous": | ||
const intervals = axisValuesToIntervals(axis.values); | ||
node.children = intervals.map(([a, b]) => { | ||
return olliVisSpecToNode( | ||
'filteredData', | ||
filterInterval(selected, axis.field, a, b), | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
[a, b], | ||
axis); | ||
}); | ||
break; | ||
} | ||
break; | ||
case "legend": | ||
const legend = guide as Legend; | ||
switch (legend.type) { | ||
case "discrete": | ||
node.children = legend.values.map(value => { | ||
return olliVisSpecToNode( | ||
'filteredData', | ||
selected.filter(d => String(d[legend.field]) === String(value)), | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
String(value), | ||
legend); | ||
}); | ||
break; | ||
case "continuous": | ||
// TODO currently unsupported | ||
break; | ||
} | ||
break; | ||
case "grid": | ||
const xAxis = chart.axes.find(axis => axis.axisType === 'x')!; | ||
const yAxis = chart.axes.find(axis => axis.axisType === 'y')!; | ||
const xIntervals = axisValuesToIntervals(xAxis.values); | ||
const yIntervals = axisValuesToIntervals(yAxis.values); | ||
const cartesian = (...a: any[][]) => a.reduce((a: any[], b: any[]) => a.flatMap((d: any) => b.map((e: any) => [d, e].flat()))); | ||
node.children = cartesian(xIntervals, yIntervals).map(([x1, x2, y1, y2], index) => { | ||
return olliVisSpecToNode( | ||
'filteredData', | ||
filterInterval( | ||
filterInterval(selected, xAxis.field, x1, x2), | ||
yAxis.field, | ||
y1, | ||
y2), | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
[[x1, x2], [y1, y2]], | ||
undefined, undefined, undefined, {i: Math.floor(index / yIntervals.length), j: index % yIntervals.length}); | ||
}); | ||
break; | ||
case "filteredData": | ||
node.children = selected.map((datum, index, array) => { | ||
return olliVisSpecToNode( | ||
'data', | ||
[datum], | ||
node, | ||
chart, | ||
fieldsUsed, | ||
facetValue, | ||
undefined, | ||
guide, | ||
index, | ||
array.length | ||
) | ||
}) | ||
break; | ||
case "data": | ||
// set the ordering of the fields for rendering to a table | ||
// put the filter values last, since user already knows the value | ||
node.tableKeys = fieldsUsed; | ||
if (guide) { | ||
node.tableKeys = node.tableKeys.filter(f => f !== guide.field).concat([guide.field]); | ||
} | ||
if (facetValue) { | ||
const facetedField = fieldsUsed[0]; | ||
node.tableKeys = node.tableKeys.filter(f => f !== facetedField).concat([facetedField]); | ||
} | ||
break; | ||
default: | ||
throw `Node type ${type} not handled in olliVisSpecToNode`; | ||
} | ||
node.description = nodeToDesc(node, olliVisSpec, facetValue, filterValue, guide, index, length); | ||
return node; | ||
} | ||
@@ -321,17 +306,83 @@ | ||
*/ | ||
function nodeToDesc(node: AccessibilityTreeNode): string { | ||
if (node.type === "multiView" || node.type === "chart") { | ||
return node.description | ||
} else if (node.type === "xAxis" || node.type === "yAxis") { | ||
return node.description | ||
} else if (node.type === `legend`) { | ||
return node.description | ||
} else if (node.type === "filteredData") { | ||
return `Range ${node.description} ${node.selected.length} ${node.selected.length === 1 ? 'value' : 'values'} in the interval` | ||
} else if (node.type === `grid`) { | ||
return node.description | ||
} else if (node.type === 'data') { | ||
return node.fieldsUsed.reduce((desc: string, currentKey: string) => `${desc} ${currentKey}: ${node.selected[0][currentKey]}`, ""); | ||
function nodeToDesc(node: AccessibilityTreeNode, olliVisSpec: OlliVisSpec, facetValue?: string, filterValue?: FilterValue, guide?: Guide, index?: number, length?: number): string { | ||
return _nodeToDesc(node, olliVisSpec, facetValue, filterValue, guide, index, length).replace(/\s+/g, ' ').trim(); | ||
function _nodeToDesc(node: AccessibilityTreeNode, olliVisSpec: OlliVisSpec, facetValue?: string, filterValue?: FilterValue, guide?: Guide, index?: number, length?: number): string { | ||
const chartType = (chart: Chart) => { | ||
if (chart.mark === 'point') { | ||
if (chart.axes.every(axis => axis.type === 'continuous')) { | ||
return 'scatterplot'; | ||
} | ||
else { | ||
return 'dot plot'; | ||
} | ||
} | ||
return chart.mark ? `${chart.mark} chart` : ''; | ||
} | ||
const chartTitle = (chart: OlliVisSpec) => (chart.title || facetValue) ? `titled "${chart.title || facetValue}"` : ''; | ||
const listAxes = (chart: Chart) => chart.axes.length === 1 ? `with axis "${chart.axes[0].title || chart.axes[0].field}"` : `with axes ${chart.axes.map(axis => `"${axis.title || axis.field}"`).join(' and ')}`; | ||
const guideTitle = (guide: Guide) => `titled "${guide.title || guide.field}"`; | ||
const axisScaleType = (axis: Axis) => `for a ${axis.scaleType || axis.type} scale`; | ||
const legendChannel = (legend: Legend) => legend.channel ? `for ${legend.channel}` : ''; | ||
const pluralize = (count: number, noun: string, suffix = 's') => `${count} ${noun}${count !== 1 ? suffix : ''}`; | ||
const guideValues = (guide: Guide) => guide.type === 'discrete' ? | ||
( | ||
guide.values.length === 2 ? | ||
`with 2 values: "${fmtValue(guide.values[0])}" and "${fmtValue(guide.values[1])}"` : | ||
`with ${pluralize(guide.values.length, 'value')} starting with "${guide.values[0]}" and ending with "${guide.values[guide.values.length - 1]}"` | ||
) : | ||
`with values from "${fmtValue(guide.values[0])}" to "${fmtValue(guide.values[guide.values.length - 1])}"`; | ||
const facetValueStr = (facetValue?: string) => facetValue ? `"${facetValue}".` : ''; | ||
const filteredValues = (guideFilterValues?: EncodingFilterValue) => { | ||
if (!guideFilterValues) return ''; | ||
else if (Array.isArray(guideFilterValues)) { | ||
return `${guideFilterValues.map(v => fmtValue(v)).join(' to ')}` | ||
} | ||
else { | ||
return `"${fmtValue(guideFilterValues)}"`; | ||
} | ||
} | ||
const filteredValuesGrid = (gridFilterValues?: GridFilterValue) => { | ||
if (!gridFilterValues) return ''; | ||
return `in ${filteredValues(gridFilterValues[0])} and ${filteredValues(gridFilterValues[1])}`; | ||
} | ||
const indexStr = (index?: number, length?: number) => index !== undefined && length !== undefined ? `${index + 1} of ${length}.` : ''; | ||
const datum = (datum: OlliDatum, node: AccessibilityTreeNode) => { | ||
return node.tableKeys?.map(field => { | ||
const value = fmtValue(datum[field]); | ||
return `"${field}": "${value}"`; | ||
}).join(', '); | ||
} | ||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); | ||
const chart = olliVisSpec as Chart; | ||
const axis = guide as Axis; | ||
const legend = guide as Legend; | ||
switch (node.type) { | ||
case 'multiView': | ||
return `A faceted chart ${chartTitle(olliVisSpec)} with ${node.children.length} views.`; | ||
case 'chart': | ||
return `${indexStr(index, length)} A ${chartType(chart)} ${chartTitle(chart)} ${listAxes(chart)}.`; | ||
case 'xAxis': | ||
case 'yAxis': | ||
return `${axis.axisType.toUpperCase()}-axis ${guideTitle(axis)} ${axisScaleType(axis)} ${guideValues(axis)}. ${facetValueStr(facetValue)}`; | ||
case 'legend': | ||
return `Legend ${guideTitle(legend)} ${legendChannel(legend)} ${guideValues(axis)}. ${facetValueStr(facetValue)}`; | ||
case 'grid': | ||
return `Grid view of ${chartType(chart)}. ${facetValueStr(facetValue)}` | ||
case 'filteredData': | ||
if (node.parent?.type === 'grid') { | ||
return `${pluralize(node.children.length, 'value')} ${filteredValuesGrid(filterValue as GridFilterValue)}. ${facetValueStr(facetValue)}`; | ||
} | ||
else { | ||
return `${capitalize(filteredValues(filterValue as EncodingFilterValue))}. ${pluralize(node.children.length, 'value')}. ${facetValueStr(facetValue)}`; | ||
} | ||
case 'data': | ||
// note: the datum description is not used by the table renderer | ||
return `${indexStr(index, length)} ${datum(node.selected[0], node)}`; | ||
default: | ||
throw `Node type ${node.type} not handled in nodeToDesc`; | ||
} | ||
} | ||
return ""; | ||
} | ||
} |
@@ -0,0 +0,0 @@ # Generating a Tree |
@@ -9,43 +9,33 @@ /** | ||
* a high-level overview of the visualization to structured elements (ex: axes and legends) to eventually specific data points. | ||
* | ||
* | ||
* description: A verbose description of the node used when rendering | ||
* | ||
* | ||
* parent: The parent node of the tree, null if this node is the root | ||
* | ||
* | ||
* children: The children tree nodes that this element has | ||
* | ||
* | ||
* selected: The array of data points that are contained in this node and all children nodes | ||
* | ||
* | ||
* type: The {@link NodeType} of this element | ||
* | ||
* | ||
* fieldsUsed: The data fields used (assists with rendering data tables) | ||
*/ | ||
export type BaseAccessibilityTreeNode = { | ||
description: string, | ||
export type AccessibilityTreeNode = { | ||
type: NodeType, | ||
parent: AccessibilityTreeNode | null, | ||
children: AccessibilityTreeNode[], | ||
selected: any[], | ||
type: NodeType, | ||
fieldsUsed: string[], | ||
description: string, | ||
children: AccessibilityTreeNode[] | ||
tableKeys?: string[], | ||
gridIndex?: { | ||
i: number, | ||
j: number | ||
} | ||
} | ||
/** | ||
* Node for visual elements such as marks, or anything that relates to the visual aspects of a chart | ||
*/ | ||
export interface VisualEncodingNode extends BaseAccessibilityTreeNode { | ||
chartType: string, | ||
export type AccessibilityTree = { | ||
root: AccessibilityTreeNode, | ||
fieldsUsed: string[] | ||
} | ||
/** | ||
* Node for structured elements such as axes and legends | ||
*/ | ||
export interface StructuralTreeNode extends BaseAccessibilityTreeNode { | ||
field: string, | ||
} | ||
/** | ||
* Union type of different tree nodes that all share {@link BaseAccessibilityTreeNode} attributes | ||
*/ | ||
export type AccessibilityTreeNode = BaseAccessibilityTreeNode | VisualEncodingNode | StructuralTreeNode; | ||
/* | ||
@@ -52,0 +42,0 @@ TODO: |
@@ -31,8 +31,3 @@ { | ||
"src" | ||
], | ||
"references": [ | ||
{ | ||
"path": "../adapters" | ||
} | ||
], | ||
] | ||
} |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
1091089
48
1589
3
4