@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
461 lines (413 loc) • 15.7 kB
JavaScript
import { escapeHtml } from '../../core/utils/html.js';
import { typeOrder, colors } from './const.js';
function fixedNum(num, prec) {
return num.toFixed(prec).replace(/\.?0+$/, '');
}
function getCoordinatesForPercent(percent) {
const x = Math.cos(2 * Math.PI * percent);
const y = Math.sin(2 * Math.PI * percent);
return [x, y];
}
// based on https://medium.com/hackernoon/a-simple-pie-chart-in-svg-dbdd653b6936
function svgPieChart(slices) {
let cumulativePercent = 0;
return [
'<svg viewBox="-1 -1 2 2" class="pie">',
...slices.map(slice => {
const [startX, startY] = getCoordinatesForPercent(cumulativePercent);
const [endX, endY] = getCoordinatesForPercent(cumulativePercent += slice.percent);
// if the slice is more than 50%, take the large arc (the long way around)
const largeArcFlag = slice.percent > .5 ? 1 : 0;
const pathData = [
`M ${startX} ${startY}`, // Move
`A 1 1 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
'L 0 0' // Line
];
return `<path d="${pathData.join(' ')}" fill="${slice.color}" stroke="black" stroke-width=".0025"/>`;
}),
'</svg>'
].join('\n');
}
function getStatCount(stat) {
let count = 0;
for (let type in stat) {
stat[type].forEach(occurrences => count += occurrences);
}
return count;
}
function getStatCounts(stat) {
let result = Object.create(null);
for (let type in stat) {
result[type] = 0;
stat[type].forEach(occurrences => result[type] += occurrences);
}
return result;
}
export function renderPropertyDetails(el, data, host) {
const { context, path, stat, name } = data;
const objectStat = stat.object;
const { map, count } = objectStat.dictMode || objectStat.properties.get(name);
const total = objectStat.dictMode
? objectStat.dictMode.count
: objectStat.size;
const output = {
name: name,
path: host.pathToQuery(path),
total,
count,
percent: fixedNum(100 * count / total, 1) + '%'
};
host.view.render(el, [
{
view: 'block',
when: 'path',
className: 'path',
content: 'text:path'
},
{
view: 'h1',
className: 'property',
content: [
'text:name',
{
view: 'html',
when: 'count != total',
data: `"<span class=\\"usage-stat optional\\">" + (
"(in <span class=\\"num\\">" + count + "</span> of <span class=\\"num\\">" + total + "</span> objects, <span class=\\"num\\">" + percent + "</span>)"
) + "</span>"`
}
]
}
], output, context);
renderTypeStat(el, {
context,
map,
count
}, host);
}
function renderTypeStat(el, { context, map, count }, host) {
const typeCounts = getStatCounts(map);
const typeStat = [];
const types = typeOrder.filter(type => type in map);
Object.entries(typeCounts).sort(([,a], [,b]) => b - a).forEach(([name, val], idx) => {
typeStat.push({
name: escapeHtml(name),
count: val,
percent: val / count,
percent100: fixedNum(100 * val / count, 1),
color: colors[idx]
});
});
host.view.render(el, {
view: 'block',
when: 'typeStat.size() > 1',
data: 'typeStat',
className: 'pie-stat',
content: [
{
view: 'block',
content: {
view: 'html',
data: svgPieChart
}
},
{
view: 'block',
content: [
'html:"<span class=\\"list-header\\">Types usage:</span>"',
{
view: 'list',
item: `html:
"<span class=\\"dot\\" style=\\"--size: 10px; background-color: " + color + "\\"></span> " +
"<span class=\\"caption\\">" + name + "</span>" +
"<span class=\\"times\\"> × " + count + " (" + percent100 + "%)</span>"
`
}
]
}
]
}, typeStat, context);
types.forEach(name =>
renderTypeDetails(el, { context, name, stat: map }, host)
);
}
export function renderTypeDetails(el, data, host) {
const context = data.context;
const stat = data.stat[data.name];
const total = getStatCount(data.stat);
const renderSections = [];
const actionButtons = [];
let output;
switch (data.name) {
case 'number': {
const values = [];
let sum = 0;
let count = 0;
let duplicated = 0;
let min = Infinity;
let max = -Infinity;
stat.forEach((occurrences, value) => {
values.push({
count: occurrences,
value
});
sum += value * occurrences;
count += occurrences;
if (occurrences > 1) {
duplicated++;
}
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
});
output = {
type: data.name,
count,
distinct: stat.size,
duplicated,
min,
max,
sum,
avg: fixedNum(sum / count, 3),
values: values.sort((a, b) => b.count - a.count || a.value - b.value)
};
if (output.distinct > 1) {
renderSections.push({
view: 'block',
className: 'overview-stat',
content: `html:
"range: (min) <span class=\\"num\\">" + min + "</span> ... " +
"<span class=\\"num\\">" + max + "</span> (max), " +
"avg: <span class=\\"num\\">" + avg + "</span>"
`
});
}
break;
}
default: {
const values = [];
let count = 0;
let duplicated = 0;
stat.forEach((occurrences, value) => {
values.push({
count: occurrences,
value
});
count += occurrences;
if (occurrences > 1) {
duplicated++;
}
});
output = {
type: data.name,
count,
distinct: stat.size,
duplicated,
values: data.name === 'object' || data.name === 'array' || data.name === 'set'
? values.sort((a, b) => b.count - a.count)
: values.sort((a, b) => b.count - a.count || (a.value > b.value) || -(a.value < b.value))
};
break;
}
}
if (data.name !== 'undefined' && data.name !== 'null') {
renderSections.unshift({
view: 'block',
className: 'overview-stat',
content: [
'html:"<span class=\\"num\\">" + count + "</span> " + (count > 1 ? "values, " : "value")',
{
view: 'switch',
when: 'count > 1',
content: [
{ when: 'distinct = 1', content: 'text:"a single unique value:"' },
{ when: 'distinct = count', content: 'text:"all unique, no duplicates"' },
{ content: [
'html:"<span class=\\"num\\">" + distinct + "</span> unique, "',
'html:duplicated = distinct ? "all occur more than once" : "<span class=\\"num\\">" + duplicated + "</span> occur more than once"'
] }
]
}
]
});
if (output.values.length > 1 &&
output.duplicated &&
data.name !== 'object' && // exclude object and array since we can't present those values in legend in short form at the moment
data.name !== 'set' &&
data.name !== 'array') {
const segments = [];
const maxSegmentsCount = output.values.length === 10 ? 10 : Math.min(9, output.values.length);
let duplicateCount = 0;
for (let i = 0; i < maxSegmentsCount; i++) {
const { count, value } = output.values[i];
duplicateCount += count;
segments.push({
name: escapeHtml(String(value)),
count,
percent: count / output.count,
percent100: fixedNum(100 * count / output.count, 1),
color: colors[i]
});
}
if (segments.length) {
const count = output.count - duplicateCount;
if (count > 0) {
segments.push({
name: '...',
count,
percent: count / output.count,
percent100: fixedNum(100 * count / output.count, 1),
color: colors[segments.length]
});
}
actionButtons.push({
view: 'button',
className: 'group',
when: '#.actions | queryAcceptChanges and querySubquery',
onClick(el, data, context) {
const path = data.path ? [...data.path] : [];
const groupBy = path.pop();
const pathStr = host.pathToQuery(path);
const rootData = context.rootData;
if (context.actions.queryAcceptChanges(rootData)) {
context.actions.querySubquery(
`${pathStr}${pathStr ? '.group(' : 'group('}=>${host.pathToQuery([groupBy])}).sort(value.size() desc)`,
rootData
);
}
}
});
renderSections.push({
view: 'block',
className: 'pie-stat',
data: segments,
content: [
{
view: 'block',
content: {
view: 'html',
data: svgPieChart
}
},
{
view: 'block',
content: [
'html:"<span class=\\"list-header\\">Dominators:</span>"',
{
view: 'list',
item: `html:
"<span class=\\"dot\\" style=\\"--size: 10px; background-color: " + color + "\\"></span> " +
"<span class=\\"caption\\" title=\\"" + name + "\\">" + name + "</span>" +
"<span class=\\"times\\"> × " + count + " (" + percent100 + "%)</span>"
`
}
]
}
]
});
}
}
if (output.values.length > 1) {
if (data.name === 'number' || data.name === 'string') {
renderSections.push({
view: 'content-filter',
name: 'filter',
data: 'values.sort(value ascN)',
content: {
view: data.name === 'string' ? 'list' : 'menu',
className: 'struct-list',
data: '.[no #.filter or value~=#.filter]',
emptyText: 'Nothing matched',
item: [
data.name === 'string'
? {
view: 'struct',
data: 'value',
match: '=#.filter'
}
: {
view: 'block',
className: 'caption',
content: 'text-match:{ text: value, match: #.filter }'
},
{
view: 'block',
when: 'count > 1',
className: 'count',
content: 'text:" × " + count'
}
]
}
});
}
} else {
if (data.name === 'number' || data.name === 'string' || data.name === 'boolean') {
renderSections.push({
view: 'struct',
data: 'values[].value'
});
}
}
if (data.name === 'object') {
renderSections.push({
view: 'list',
className: 'struct-list',
data: 'values',
item: [
'struct:value',
{
view: 'block',
when: 'count > 1',
className: 'count',
content: 'text:" × " + count'
}
]
});
}
if ((data.name === 'array' || data.name === 'set') && Object.keys(stat.map).length) {
renderSections.push({
view: 'block',
className: data.name === 'array' ? 'array-types' : 'set-types',
postRender(el) {
renderTypeStat(el, { ...stat, context }, host);
}
});
}
}
host.view.render(el, [
{
view: 'block',
when: 'path',
className: 'path',
data: data => host.pathToQuery(data.path),
content: 'text'
},
{
view: 'block',
className: 'signature-action-buttons',
content: actionButtons
},
{
view: 'h1',
className: 'type',
content: [
'text:name',
`html:"<span class=\\"usage-stat\\">" + (
count = total
? "only this type is used"
: "used in <span class=\\"num\\">" + count + "</span> of <span class=\\"num\\">" + total + "</span> cases (<span class=\\"num\\">" + percent + "</span>)"
) + "</span>"`
]
},
...renderSections
], {
...output,
name: data.name,
path: data.path,
total,
percent: fixedNum(100 * output.count / total, 1) + '%'
}, context);
}