Rewrite insights in typescript and snabbdom

pull/9521/head
Albert Ford 2021-08-02 18:26:17 -07:00
parent 84384b2222
commit 39c51a5ae6
18 changed files with 681 additions and 486 deletions

View File

@ -10,12 +10,14 @@ module.exports = targets => {
const target = targets[args['config-plugin'] || 'main'];
return {
input: target.input,
external: target.external,
output: [
prod
? {
file: `../../public/compiled/${target.output}.min.js`,
format: 'iife',
name: target.name,
globals: target.globals,
plugins: [
terser({
safari10: true,
@ -30,6 +32,7 @@ module.exports = targets => {
file: `../../public/compiled/${target.output}.js`,
format: 'iife',
name: target.name,
globals: target.globals,
},
],
plugins: [

View File

@ -0,0 +1,5 @@
export default function numeral(n: number): Numeral;
export interface Numeral {
format(f: string): string;
}

View File

@ -14,12 +14,13 @@
"author": "ornicar",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@build/rollupProject": "2.0.0"
"@build/rollupProject": "2.0.0",
"@types/lichess": "2.0.0"
},
"dependencies": {
"common": "2.0.0",
"mithril": "github:ornicar/mithril.js#lila-1",
"numeral": "^1.5"
"numeral": "^1.5",
"snabbdom": "^3.0.1"
},
"scripts": {
"dev": "rollup --config",

View File

@ -3,8 +3,11 @@ import rollupProject from '@build/rollupProject';
export default rollupProject({
main: {
name: 'LichessInsight',
input: 'src/main.js',
input: 'src/main.ts',
output: 'insight',
js: true,
external: ['highcharts'],
globals: {
highcharts: 'Highcharts',
},
},
});

View File

@ -1,80 +1,55 @@
var m = require('mithril');
import Ctrl from './ctrl';
import { MaybeVNode, onInsert } from 'common/snabbdom';
import { h } from 'snabbdom';
import { Categ, Dimension, Metric } from './interfaces';
module.exports = function (ctrl) {
return m('div.axis-form', [
m(
const selectData = (onClick: (v: { value: string }) => void) => ({
attrs: { multiple: true },
hook: onInsert(e =>
$(e).multipleSelect({
width: '200px',
maxHeight: '400px',
single: true,
onClick,
})
),
});
const optgroup =
<T>(callback: (item: T) => MaybeVNode) =>
(categ: Categ<T>) =>
h('optgroup', { attrs: { label: categ.name } }, categ.items.map(callback));
const option = (ctrl: Ctrl, item: Metric | Dimension, axis: 'metric' | 'dimension') =>
h(
'option',
{
attrs: {
title: item.description.replace(/<a[^>]*>[^>]+<\/a[^>]*>/, ''),
value: item.key,
selected: ctrl.vm[axis].key === item.key,
// was commented out:
// if axis === 'metric'
// disabled: !ctrl.validCombination(ctrl.vm.dimension, item),
// if axis === 'dimension'
// disabled: !ctrl.validCombination(item, ctrl.vm.metric),
},
},
item.name
);
export default function (ctrl: Ctrl) {
return h('div.axis-form', [
h(
'select.ms.metric',
{
multiple: true,
config: function (e, isUpdate) {
$(e).multipleSelect({
width: '200px',
maxHeight: '400px',
single: true,
onClick: function (v) {
ctrl.setMetric(v.value);
},
});
},
},
ctrl.ui.metricCategs.map(function (categ) {
return m(
'optgroup',
{
label: categ.name,
},
categ.items.map(function (y) {
return m(
'option',
{
title: y.description.replace(/<a[^>]*>[^>]+<\/a[^>]*>/, ''),
value: y.key,
// disabled: !ctrl.validCombination(ctrl.vm.dimension, y),
selected: ctrl.vm.metric.key === y.key,
},
y.name
);
})
);
})
selectData(v => ctrl.setMetric(v.value)),
ctrl.ui.metricCategs.map(optgroup(y => option(ctrl, y, 'metric')))
),
m('span.by', 'by'),
m(
h('span.by', 'by'),
h(
'select.ms.dimension',
{
multiple: true,
config: function (e, isUpdate) {
$(e).multipleSelect({
width: '200px',
maxHeight: '400px',
single: true,
onClick: function (v) {
ctrl.setDimension(v.value);
},
});
},
},
ctrl.ui.dimensionCategs.map(function (categ) {
return m(
'optgroup',
{
label: categ.name,
},
categ.items.map(function (x) {
if (x.key === 'period') return;
return m(
'option',
{
title: x.description.replace(/<a[^>]*>[^>]+<\/a[^>]*>/, ''),
value: x.key,
// disabled: !ctrl.validCombination(x, ctrl.vm.metric),
selected: ctrl.vm.dimension.key === x.key,
},
x.name
);
})
);
})
selectData(v => ctrl.setDimension(v.value)),
ctrl.ui.dimensionCategs.map(optgroup(x => (x.key !== 'period' ? option(ctrl, x, 'dimension') : undefined)))
),
]);
};
}

View File

@ -1,29 +1,32 @@
var m = require('mithril');
import Ctrl from './ctrl';
import { h } from 'snabbdom';
import { Game } from './interfaces';
import { onInsert } from 'common/snabbdom';
function miniGame(game) {
return m(
function miniGame(game: Game) {
return h(
'a',
{
key: game.id,
href: `/${game.id}/${game.color}`,
attrs: {
key: game.id,
href: `/${game.id}/${game.color}`,
},
},
[
m('span.mini-board.is2d', {
'data-state': `${game.fen},${game.color},${game.lastMove}`,
config(el, isUpdate) {
if (!isUpdate) lichess.miniBoard.init(el);
},
h('span.mini-board.is2d', {
attrs: { 'data-state': `${game.fen},${game.color},${game.lastMove}` },
hook: onInsert(el => lichess.miniBoard.init(el)),
}),
m('span.vstext', [
m('span.vstext__pl', [
h('span.vstext', [
h('span.vstext__pl', [
game.user1.name,
m('br'),
h('br'),
game.user1.title ? game.user1.title + ' ' : '',
game.user1.rating,
]),
m('span.vstext__op', [
h('span.vstext__op', [
game.user2.name,
m('br'),
h('br'),
game.user2.rating,
game.user2.title ? ' ' + game.user2.title : '',
]),
@ -32,11 +35,11 @@ function miniGame(game) {
);
}
module.exports = function (ctrl) {
export default function (ctrl: Ctrl) {
if (!ctrl.vm.answer) return;
return m('div.game-sample.box', [
m('div.top', 'Some of the games used to generate this insight'),
m('div.boards', ctrl.vm.answer.games.map(miniGame)),
return h('div.game-sample.box', [
h('div.top', 'Some of the games used to generate this insight'),
h('div.boards', ctrl.vm.answer.games.map(miniGame)),
]);
};
}

View File

@ -1,39 +1,56 @@
var m = require('mithril');
import { h, VNode } from 'snabbdom';
import Ctrl from './ctrl';
import { Chart } from './interfaces';
import * as Highcharts from 'highcharts';
function metricDataTypeFormat(dt) {
function metricDataTypeFormat(dt: string) {
if (dt === 'seconds') return '{point.y:.1f}';
if (dt === 'average') return '{point.y:,.1f}';
if (dt === 'percent') return '{point.y:.1f}%';
return '{point.y:,.0f}';
}
function dimensionDataTypeFormat(dt) {
function dimensionDataTypeFormat(dt: string) {
if (dt === 'date') return '{value:%Y-%m-%d}';
return '{value}';
}
function yAxisTypeFormat(dt) {
function yAxisTypeFormat(dt: string) {
if (dt === 'seconds') return '{value:.1f}';
if (dt === 'average') return '{value:,.1f}';
if (dt === 'percent') return '{value:.0f}%';
return '{value:,.0f}';
}
var colors = {
const colors = {
green: '#759900',
red: '#dc322f',
orange: '#d59120',
blue: '#007599',
};
var resultColors = {
const resultColors = {
Victory: colors.green,
Draw: colors.blue,
Defeat: colors.red,
};
var theme = (function () {
var light = $('body').hasClass('light');
var t = {
interface Theme {
light: boolean;
text: {
weak: string;
strong: string;
};
line: {
weak: string;
strong: string;
fat: string;
};
colors?: string[];
}
const theme = (function () {
const light = $('body').hasClass('light');
const t: Theme = {
light: light,
text: {
weak: light ? '#808080' : '#9a9a9a',
@ -62,8 +79,8 @@ var theme = (function () {
return t;
})();
function makeChart(el, data) {
var sizeSerie = {
function makeChart(el: HTMLElement, data: Chart) {
const sizeSerie = {
name: data.sizeSerie.name,
data: data.sizeSerie.data,
yAxis: 1,
@ -74,8 +91,8 @@ function makeChart(el, data) {
},
color: 'rgba(120,120,120,0.2)',
};
var valueSeries = data.series.map(function (s) {
var c = {
const valueSeries = data.series.map(function (s) {
const c: Highcharts.ColumnChartSeriesOptions = {
name: s.name,
data: s.data,
yAxis: 0,
@ -98,20 +115,20 @@ function makeChart(el, data) {
);
})(),
shared: true,
},
} as Highcharts.SeriesTooltipOptions,
};
if (data.valueYaxis.name === 'Game result') c.color = resultColors[s.name];
if (data.valueYaxis.name === 'Game result') c.color = resultColors[s.name as 'Victory' | 'Draw' | 'Defeat'];
return c;
});
var chartConf = {
const chartConf: Highcharts.Options = {
chart: {
type: 'column',
alignTicks: data.valueYaxis.dataType !== 'percent',
spacing: [20, 7, 20, 5],
backgroundColor: null,
backgroundColor: undefined,
borderWidth: 0,
borderRadius: 0,
plotBackgroundColor: null,
plotBackgroundColor: undefined,
plotShadow: false,
plotBorderWidth: 0,
style: {
@ -119,25 +136,25 @@ function makeChart(el, data) {
},
},
title: {
text: null,
text: undefined,
},
xAxis: {
type: data.xAxis.dataType === 'date' ? 'datetime' : 'linear',
categories: data.xAxis.categories.map(function (v) {
return data.xAxis.dataType === 'date' ? v * 1000 : v;
return `${data.xAxis.dataType === 'date' ? v * 1000 : v}`;
}),
crosshair: true,
labels: {
format: dimensionDataTypeFormat(data.xAxis.dataType),
style: {
color: theme.text.weak,
fontSize: 9,
fontSize: '9',
},
},
title: {
style: {
color: theme.text.weak,
fontSize: 9,
fontSize: '9',
},
},
gridLineColor: theme.line.weak,
@ -145,9 +162,9 @@ function makeChart(el, data) {
tickColor: theme.line.strong,
},
yAxis: [data.valueYaxis, data.sizeYaxis].map(function (a, i) {
var isPercent = data.valueYaxis.dataType === 'percent';
var isSize = i % 2 === 1;
var c = {
const isPercent = data.valueYaxis.dataType === 'percent';
const isSize = i % 2 === 1;
const c: Highcharts.AxisOptions = {
opposite: isSize,
min: !isSize && isPercent ? 0 : undefined,
max: !isSize && isPercent ? 100 : undefined,
@ -155,14 +172,14 @@ function makeChart(el, data) {
format: yAxisTypeFormat(a.dataType),
style: {
color: theme.text.weak,
fontSize: 9,
fontSize: '9',
},
},
title: {
text: i === 1 ? a.name : false,
text: i === 1 ? a.name : '',
style: {
color: theme.text.weak,
fontSize: 9,
fontSize: '9',
},
},
gridLineColor: theme.line.weak,
@ -170,7 +187,7 @@ function makeChart(el, data) {
if (isSize && isPercent) {
c.minorGridLineWidth = 0;
c.gridLineWidth = 0;
c.alternateGridColor = null;
c.alternateGridColor = undefined;
}
return c;
}),
@ -235,20 +252,31 @@ function makeChart(el, data) {
Highcharts.chart(el, chartConf);
}
function empty(txt) {
return m('div.chart.empty', [m('i[data-icon=]'), txt]);
function empty(txt: string) {
return h('div.chart.empty', [
h('i', {
attrs: { 'data-icon': '' },
}),
txt,
]);
}
module.exports = function (ctrl) {
function chartHook(vnode: VNode, ctrl: Ctrl) {
const el = vnode.elm as HTMLElement;
if (ctrl.vm.loading || !ctrl.vm.answer) {
$(el).html(lichess.spinnerHtml);
} else {
makeChart(el, ctrl.vm.answer);
}
}
export default function (ctrl: Ctrl) {
if (!ctrl.validCombinationCurrent()) return empty('Invalid dimension/metric combination');
if (!ctrl.vm.answer.series.length) return empty('No data. Try widening or clearing the filters.');
return [
m('div.chart', {
config: function (el) {
if (ctrl.vm.loading) return;
makeChart(el, ctrl.vm.answer);
},
}),
ctrl.vm.loading ? m.trust(lichess.spinnerHtml) : null,
];
};
if (!ctrl.vm.answer?.series.length) return empty('No data. Try widening or clearing the filters.');
return h('div.chart', {
hook: {
insert: vnode => chartHook(vnode, ctrl),
update: (_oldVnode, newVnode) => chartHook(newVnode, ctrl),
},
});
}

View File

@ -1,173 +1,160 @@
var m = require('mithril');
var throttle = require('common/throttle').default;
import throttle from 'common/throttle';
import * as xhr from 'common/xhr';
import { Chart, Dimension, Env, Metric, Question, UI, EnvUser, Vm, Filters } from './interfaces';
module.exports = function (env, domElement) {
this.ui = env.ui;
this.user = env.user;
this.own = env.myUserId === this.user.id;
this.dimensions = [].concat.apply(
[],
this.ui.dimensionCategs.map(function (c) {
return c.items;
})
);
this.metrics = [].concat.apply(
[],
this.ui.metricCategs.map(function (c) {
return c.items;
})
);
export default class {
env: Env;
ui: UI;
user: EnvUser;
own: boolean;
domElement: Element;
redraw: () => void;
var findMetric = function (key) {
return this.metrics.find(function (x) {
return x.key === key;
});
}.bind(this);
dimensions: Dimension[];
metrics: Metric[];
var findDimension = function (key) {
return this.dimensions.find(function (x) {
return x.key === key;
});
}.bind(this);
vm: Vm;
this.vm = {
metric: findMetric(env.initialQuestion.metric),
dimension: findDimension(env.initialQuestion.dimension),
filters: env.initialQuestion.filters,
loading: true,
broken: false,
answer: null,
panel: Object.keys(env.initialQuestion.filters).length ? 'filter' : 'preset',
};
constructor(env: Env, domElement: Element, redraw: () => void) {
this.env = env;
this.ui = env.ui;
this.user = env.user;
this.own = env.myUserId === env.user.id;
this.domElement = domElement;
this.redraw = redraw;
this.setPanel = function (p) {
this.dimensions = Array.prototype.concat.apply(
[],
env.ui.dimensionCategs.map(c => c.items)
);
this.metrics = Array.prototype.concat.apply(
[],
env.ui.metricCategs.map(c => c.items)
);
this.vm = {
metric: this.findMetric(this.env.initialQuestion.metric)!,
dimension: this.findDimension(this.env.initialQuestion.dimension)!,
filters: this.env.initialQuestion.filters,
loading: true,
broken: false,
answer: null,
panel: Object.keys(env.initialQuestion.filters).length ? 'filter' : 'preset',
};
}
private findMetric = (key: string) => this.metrics.find(x => x.key === key);
private findDimension = (key: string) => this.dimensions.find(x => x.key === key);
setPanel(p: 'filter' | 'preset') {
this.vm.panel = p;
m.redraw();
}.bind(this);
this.redraw();
}
var reset = function () {
reset() {
this.vm.metric = this.metrics[0];
this.vm.dimension = this.dimensions[0];
this.vm.filters = {};
}.bind(this);
}
var askQuestion = throttle(
1000,
function () {
if (!this.validCombinationCurrent()) reset();
this.pushState();
this.vm.loading = true;
this.vm.broken = false;
m.redraw();
setTimeout(
function () {
m.request({
method: 'post',
url: env.postUrl,
data: {
metric: this.vm.metric.key,
dimension: this.vm.dimension.key,
filters: this.vm.filters,
},
deserialize: function (d) {
try {
return JSON.parse(d);
} catch (e) {
throw new Error(d);
}
},
}).then(
function (answer) {
this.vm.answer = answer;
this.vm.loading = false;
}.bind(this),
function () {
this.vm.loading = false;
this.vm.broken = true;
m.redraw();
}.bind(this)
);
}.bind(this),
1
);
}.bind(this)
);
askQuestion = throttle(1000, () => {
if (!this.validCombinationCurrent()) this.reset();
this.pushState();
this.vm.loading = true;
this.vm.broken = false;
this.redraw();
setTimeout(() => {
xhr
.json(this.env.postUrl, {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metric: this.vm.metric.key,
dimension: this.vm.dimension.key,
filters: this.vm.filters,
}),
})
.then(
(answer: Chart) => {
this.vm.answer = answer;
this.vm.loading = false;
this.redraw();
},
() => {
this.vm.loading = false;
this.vm.broken = true;
this.redraw();
}
);
}, 1);
});
this.makeUrl = function (dKey, mKey, filters) {
var url = [env.pageUrl, mKey, dKey].join('/');
var filters = Object.keys(filters)
makeUrl(dKey: string, mKey: string, filters: Filters) {
let url = [this.env.pageUrl, mKey, dKey].join('/');
const filtersStr = Object.keys(filters)
.map(function (filterKey) {
return filterKey + ':' + filters[filterKey].join(',');
})
.join('/');
if (filters.length) url += '/' + filters;
if (filtersStr.length) url += '/' + filtersStr;
return url;
};
}
this.makeCurrentUrl = function () {
makeCurrentUrl() {
return this.makeUrl(this.vm.dimension.key, this.vm.metric.key, this.vm.filters);
}.bind(this);
}
this.pushState = function () {
history.replaceState({}, null, this.makeCurrentUrl());
}.bind(this);
pushState() {
history.replaceState({}, '', this.makeCurrentUrl());
}
this.validCombination = function (dimension, metric) {
validCombination(dimension?: Dimension, metric?: Metric) {
return dimension && metric && (dimension.position === 'game' || metric.position === 'move');
};
this.validCombinationCurrent = function () {
}
validCombinationCurrent() {
return this.validCombination(this.vm.dimension, this.vm.metric);
}.bind(this);
}
this.setMetric = function (key) {
this.vm.metric = findMetric(key);
setMetric(key: string) {
this.vm.metric = this.findMetric(key)!;
if (!this.validCombinationCurrent())
this.vm.dimension = this.dimensions.find(
function (d) {
return this.validCombination(d, this.vm.metric);
}.bind(this)
);
this.vm.dimension = this.dimensions.find(d => this.validCombination(d, this.vm.metric))!;
this.vm.panel = 'filter';
askQuestion();
}.bind(this);
this.askQuestion();
}
this.setDimension = function (key) {
this.vm.dimension = findDimension(key);
setDimension(key: string) {
this.vm.dimension = this.findDimension(key)!;
if (!this.validCombinationCurrent())
this.vm.metric = this.metrics.find(
function (m) {
return this.validCombination(this.vm.dimension, m);
}.bind(this)
);
this.vm.metric = this.metrics.find(m => this.validCombination(this.vm.dimension, m))!;
this.vm.panel = 'filter';
askQuestion();
}.bind(this);
this.askQuestion();
}
this.setFilter = function (dimensionKey, valueKeys) {
setFilter(dimensionKey: string, valueKeys: string[]) {
if (!valueKeys.length) delete this.vm.filters[dimensionKey];
else this.vm.filters[dimensionKey] = valueKeys;
askQuestion();
}.bind(this);
this.askQuestion();
}
this.setQuestion = function (q) {
this.vm.dimension = findDimension(q.dimension);
this.vm.metric = findMetric(q.metric);
setQuestion(q: Question) {
this.vm.dimension = this.findDimension(q.dimension)!;
this.vm.metric = this.findMetric(q.metric)!;
this.vm.filters = q.filters;
askQuestion();
$(domElement).find('select.ms').multipleSelect('open');
setTimeout(function () {
$(domElement).find('select.ms').multipleSelect('close');
this.askQuestion();
$(this.domElement).find('select.ms').multipleSelect('open');
setTimeout(() => {
$(this.domElement).find('select.ms').multipleSelect('close');
}, 1000);
}.bind(this);
}
this.clearFilters = function () {
clearFilters() {
if (Object.keys(this.vm.filters).length) {
this.vm.filters = {};
askQuestion();
this.askQuestion();
}
}.bind(this);
}
// this.trans = lichess.trans(env.i18n);
askQuestion();
};
}

View File

@ -1,36 +1,45 @@
var m = require('mithril');
import { h, VNode } from 'snabbdom';
import Ctrl from './ctrl';
import { Dimension } from './interfaces';
function select(ctrl) {
return function (dimension) {
function select(ctrl: Ctrl) {
return function (dimension: Dimension) {
if (dimension.key === 'date') return;
var single = dimension.key === 'period';
return m(
const single = dimension.key === 'period';
function multipleSelect(vnode: VNode) {
$(vnode.elm).multipleSelect({
placeholder: dimension.name,
width: '100%',
selectAll: false,
filter: dimension.key === 'opening',
single: single,
minimumCountSelected: 10,
onClick: function (view) {
const values = single ? [view.value] : $(vnode.elm).multipleSelect('getSelects');
ctrl.setFilter(dimension.key, values);
},
});
}
return h(
'select',
{
multiple: true,
config: function (e, isUpdate) {
if (isUpdate && ctrl.vm.filters[dimension.key]) return;
$(e).multipleSelect({
placeholder: dimension.name,
width: '100%',
selectAll: false,
filter: dimension.key === 'opening',
single: single,
minimumCountSelected: 10,
onClick: function (view) {
var values = single ? [view.value] : $(e).multipleSelect('getSelects');
ctrl.setFilter(dimension.key, values);
},
});
attrs: { multiple: true },
hook: {
insert: multipleSelect,
update: (_oldVnode, vnode) => {
if (!ctrl.vm.filters[dimension.key]) multipleSelect(vnode);
},
},
},
dimension.values.map(function (value) {
var selected = ctrl.vm.filters[dimension.key];
return m(
const selected = ctrl.vm.filters[dimension.key];
return h(
'option',
{
value: value.key,
selected: selected && selected.includes(value.key),
attrs: {
value: value.key,
selected: selected && selected.includes(value.key),
},
},
value.name
);
@ -39,13 +48,14 @@ function select(ctrl) {
};
}
module.exports = function (ctrl) {
return m('div.filters', [
m(
export default function (ctrl: Ctrl) {
return h(
'div.filters',
h(
'div.items',
ctrl.ui.dimensionCategs.map(function (categ) {
return m('div.categ.box', [m('div.top', categ.name), categ.items.map(select(ctrl))]);
return h('div.categ.box', [h('div.top', categ.name), ...categ.items.map(select(ctrl))]);
})
),
]);
};
)
);
}

71
ui/insight/src/global.d.ts vendored 100644
View File

@ -0,0 +1,71 @@
// Extend the global lichess object with a function defined in
// public/javascripts/insight-refresh.js
interface Lichess {
refreshInsightForm(): void;
}
/* Type definitions for multiple-select.js */
// multiple-select.js uses jquery, but typescript thinks $ is Cash, not jquery.
// So as a hack, we extend the Cash interface knowing that at runtime, it will
// actually be a jquery object.
interface Cash {
multipleSelect(method: 'getSelects'): string[];
multipleSelect(
method:
| 'setSelects'
| 'enable'
| 'disable'
| 'open'
| 'close'
| 'checkAll'
| 'uncheckAll'
| 'focus'
| 'blur'
| 'refresh'
| 'close'
): void;
multipleSelect(option: MultiSelectOpts): void;
}
interface MultiSelectOpts {
name?: string;
isOpen?: boolean;
placeholder?: string;
selectAll?: boolean;
selectAllDelimiter?: string[];
minimumCountSelected?: number;
ellipsis?: boolean;
multiple?: boolean;
multipleWidth?: number;
single?: boolean;
filter?: boolean;
width?: string;
dropWidth?: number;
maxHeight?: string;
container?: Element;
position?: string;
keepOpen?: boolean;
animate?: string;
displayValues?: boolean;
delimiter?: string;
addTitle?: boolean;
filterAcceptOnEnter?: boolean;
hideOptgroupCheckboxes?: boolean;
selectAllText?: string;
allSelected?: string;
countSelected?: string;
noMatchesFound?: string;
styler?(): boolean;
textTemplate?($elm: Element): string;
labelTemplate?($elm: Element): string;
onOpen?(): any;
onClose?(): any;
onCheckAll?(): any;
onUncheckAll?(): any;
onFocus?(): any;
onBlur?(): any;
onOptgroupClick?(): any;
onClick?(view: { label: string; value: string; checked: boolean; instance: any }): any;
onFilter?(): any;
}

View File

@ -1,14 +1,15 @@
var m = require('mithril');
import { h } from 'snabbdom';
import Ctrl from './ctrl';
module.exports = function (ctrl) {
return m('div.help.box', [
m('div.top', 'Definitions'),
m(
export default function (ctrl: Ctrl) {
return h('div.help.box', [
h('div.top', 'Definitions'),
h(
'div.content',
['metric', 'dimension'].map(function (type) {
var data = ctrl.vm[type];
return m('section.' + type, [m('h3', data.name), m('p', m.trust(data.description))]);
['metric', 'dimension'].map(type => {
const data = ctrl.vm[type as 'metric' | 'dimension'];
return h('section.' + type, [h('h3', data.name), h('p', data.description)]);
})
),
]);
};
}

View File

@ -1,45 +1,59 @@
var m = require('mithril');
import { onInsert } from 'common/snabbdom';
import { h } from 'snabbdom';
import Ctrl from './ctrl';
var shareStates = ['nobody', 'friends only', 'everybody'];
const shareStates = ['nobody', 'friends only', 'everybody'];
module.exports = function (ctrl) {
var shareText = 'Shared with ' + shareStates[ctrl.user.shareId] + '.';
return m('div.info.box', [
m('div.top', [
m(
'a.username.user-link.insight-ulpt',
{
href: '/@/' + ctrl.user.name,
},
ctrl.user.name
),
]),
m('div.content', [
m('p', ['Insights over ', m('strong', ctrl.user.nbGames), ' rated games.']),
m(
export default function (ctrl: Ctrl) {
const shareText = 'Shared with ' + shareStates[ctrl.user.shareId] + '.';
return h('div.info.box', [
h('div.top', [h('a.username.user-link.insight-ulpt', { attrs: { href: '/@/' + ctrl.user.name } }, ctrl.user.name)]),
h('div.content', [
h('p', ['Insights over ', h('strong', ctrl.user.nbGames), ' rated games.']),
h(
'p.share',
ctrl.own
? m(
? h(
'a',
{
href: '/account/preferences/privacy',
target: '_blank',
rel: 'noopener',
attrs: {
href: '/account/preferences/privacy',
target: '_blank',
rel: 'noopener',
},
},
shareText
)
: shareText
),
]),
m('div.refresh', {
config: function (e, isUpdate) {
if (isUpdate) return;
var $ref = $('.insight-stale');
if ($ref.length) {
$(e).html($ref.show());
lichess.refreshInsightForm();
}
},
}),
h(
'div.refresh',
ctrl.env.user.stale
? h('div.insight-stale', [
h('p', 'There are new games to learn from!'),
h(
'form.insight-refresh',
{
attrs: {
action: `/insights/refresh/${ctrl.env.user.id}`,
method: 'post',
},
hook: onInsert(_el => lichess.refreshInsightForm()),
},
[
h('button.button.text', { attrs: { 'data-icon': '' } }, 'Upate insights'),
h(
'div.crunching.none',
{
hook: onInsert(el => el.insertAdjacentHTML('afterbegin', lichess.spinnerHtml)),
},
[h('br'), h('p', h('strong', 'Now crunching data just for you!'))]
),
]
),
])
: null
),
]);
};
}

View File

@ -0,0 +1,112 @@
export interface Vm {
metric: Metric;
dimension: Dimension;
filters: Filters;
loading: boolean;
broken: boolean;
answer: Chart | null;
panel: 'filter' | 'preset';
}
export interface Env {
i18n: { [key: string]: string };
initialQuestion: Question;
myUserId: string;
pageUrl: string;
postUrl: string;
ui: UI;
user: EnvUser;
}
export interface EnvUser {
id: string;
name: string;
nbGames: number;
shareId: number;
stale: boolean;
}
export interface Question {
metric: string;
dimension: string;
filters: Filters;
}
export type Filters = {
[L in string]: string[];
};
export interface UI {
dimensionCategs: Categ<Dimension>[];
metricCategs: Categ<Metric>[];
presets: Preset[];
}
export interface Categ<T> {
name: string;
items: T[];
}
interface Preset {
name: string;
dimension: string;
metric: string;
filters: Filters;
}
export interface Metric {
key: string;
name: string;
position: string;
description: string;
}
export interface Dimension extends Metric {
values: {
key: string;
name: string;
}[];
}
export interface Chart {
question: Question;
xAxis: Xaxis;
valueYaxis: Yaxis;
sizeYaxis: Yaxis;
series: Serie[];
sizeSerie: Serie;
games: Game[];
}
interface Xaxis {
name: string;
categories: number[];
dataType: string;
}
interface Yaxis {
name: string;
dataType: string;
}
interface Serie {
name: string;
dataType: string;
stack?: string;
data: number[];
}
export interface Game {
id: string;
fen: string;
color: string;
lastMove: string;
user1: Player;
user2: Player;
}
interface Player {
name: string;
title?: string;
rating: number;
}

View File

@ -1,17 +1,24 @@
var m = require('mithril');
import { attributesModule, classModule, init } from 'snabbdom';
var ctrl = require('./ctrl');
var view = require('./view');
import Ctrl from './ctrl';
import { Env } from './interfaces';
import view from './view';
module.exports = function (element, opts) {
var controller = new ctrl(opts, element);
const patch = init([classModule, attributesModule]);
m.module(element, {
controller: function () {
return controller;
},
view: view,
});
export default function (element: Element, opts: Env) {
const ctrl = new Ctrl(opts, element, redraw);
return controller;
};
const blueprint = view(ctrl);
let vnode = patch(element, blueprint);
// Wait until vnode has been intialized to call askQuestion because
// askQuestion can call redraw
ctrl.askQuestion();
function redraw() {
vnode = patch(vnode, view(ctrl));
}
return ctrl;
}

View File

@ -1,21 +1,21 @@
var m = require('mithril');
import { bind } from 'common/snabbdom';
import { h } from 'snabbdom';
import Ctrl from './ctrl';
module.exports = function (ctrl) {
return m(
export default function (ctrl: Ctrl) {
return h(
'div.box.presets',
ctrl.ui.presets.map(function (p) {
var active = ctrl.makeUrl(p.dimension, p.metric, p.filters) === ctrl.makeCurrentUrl();
return m(
'a',
const active = ctrl.makeUrl(p.dimension, p.metric, p.filters) === ctrl.makeCurrentUrl();
return h(
'a.preset.text',
{
class: 'preset text' + (active ? ' active' : ''),
'data-icon': '',
onclick: function () {
ctrl.setQuestion(p);
},
class: { active },
attrs: { 'data-icon': '' },
hook: bind('click', () => ctrl.setQuestion(p)),
},
p.name
);
})
);
};
}

View File

@ -1,9 +1,10 @@
var m = require('mithril');
var numeral = require('numeral');
import { h } from 'snabbdom';
import numeral from 'numeral';
import Ctrl from './ctrl';
function formatNumber(dt, n) {
function formatNumber(dt: string, n: number) {
if (dt === 'percent') n = n / 100;
var f;
let f;
if (dt === 'seconds') f = '0.00';
else if (dt === 'average') f = '0.00';
else if (dt === 'percent') f = '0.0%';
@ -11,38 +12,32 @@ function formatNumber(dt, n) {
return numeral(n).format(f);
}
function formatSerieName(dt, n) {
function formatSerieName(dt: string, n: number) {
if (dt === 'date') return new Date(n * 1000).toLocaleDateString();
return n;
}
module.exports = {
vert: function (ctrl) {
var answer = ctrl.vm.answer;
if (!answer) return null;
return m('table.slist', [
m(
'thead',
m('tr', [
m('th', answer.xAxis.name),
answer.series.map(function (serie) {
return m('th', serie.name);
}),
m('th', answer.sizeYaxis.name),
])
),
m(
'tbody',
answer.xAxis.categories.map(function (c, i) {
return m('tr', [
m('th', formatSerieName(answer.xAxis.dataType, c)),
answer.series.map(function (serie) {
return m('td.data', formatNumber(serie.dataType, serie.data[i]));
}),
m('td.size', formatNumber(answer.sizeSerie.dataType, answer.sizeSerie.data[i])),
]);
})
),
]);
},
};
export function vert(ctrl: Ctrl) {
const answer = ctrl.vm.answer;
if (!answer) return null;
return h('table.slist', [
h(
'thead',
h('tr', [
h('th', answer.xAxis.name),
...answer.series.map(serie => h('th', serie.name)),
h('th', answer.sizeYaxis.name),
])
),
h(
'tbody',
answer.xAxis.categories.map((c, i) => {
return h('tr', [
h('th', formatSerieName(answer.xAxis.dataType, c)),
...answer.series.map(serie => h('td.data', formatNumber(serie.dataType, serie.data[i]))),
h('td.size', formatNumber(answer.sizeSerie.dataType, answer.sizeSerie.data[i])),
]);
})
),
]);
}

View File

@ -1,76 +1,57 @@
var m = require('mithril');
var axis = require('./axis');
var filters = require('./filters');
var presets = require('./presets');
var chart = require('./chart');
var table = require('./table');
var help = require('./help');
var info = require('./info');
var boards = require('./boards');
import { h, thunk } from 'snabbdom';
import axis from './axis';
import filters from './filters';
import presets from './presets';
import chart from './chart';
import { vert } from './table';
import help from './help';
import info from './info';
import boards from './boards';
import Ctrl from './ctrl';
import { bind } from 'common/snabbdom';
function cache(view, dataToKey) {
var prev = null;
return function (data) {
var key = dataToKey(data);
if (prev === key)
return {
subtree: 'retain',
};
prev = key;
return view(data);
};
}
const renderMain = (ctrl: Ctrl, _cacheKey: string | boolean) => {
if (ctrl.vm.broken)
return h('div.broken', [
h('i', { attrs: { 'data-icon': '' } }),
'Insights are unavailable.',
h('br'),
'Please try again later.',
]);
if (!ctrl.vm.answer) return h('div'); // returning undefined breaks snabbdom's thunks
return h('div', [chart(ctrl), vert(ctrl), boards(ctrl)]);
};
var renderMain = cache(
function (ctrl) {
if (ctrl.vm.broken)
return m('div.broken', [m('i[data-icon=]'), 'Insights are unavailable.', m('br'), 'Please try again later.']);
if (!ctrl.vm.answer) return;
return m('div', [chart(ctrl), table.vert(ctrl), boards(ctrl)]);
},
function (ctrl) {
var q = ctrl.vm.answer ? ctrl.vm.answer.question : null;
return q ? ctrl.makeUrl(q.dimension, q.metric, q.filters) : ctrl.vm.broken;
}
);
// Key that determines whether or not renderMain needs to get rerendered
const cacheKey = (ctrl: Ctrl) => {
const q = ctrl.vm.answer?.question;
return q ? ctrl.makeUrl(q.dimension, q.metric, q.filters) : ctrl.vm.broken;
};
module.exports = function (ctrl) {
return m(
'div',
{
class: ctrl.vm.loading ? 'loading' : 'ready',
},
[
m('div.left-side', [
const tabData = (ctrl: Ctrl, panel: 'filter' | 'preset') => ({
class: { active: ctrl.vm.panel === panel },
attrs: { 'data-panel': panel },
hook: bind('click', () => ctrl.setPanel(panel)),
});
export default function (ctrl: Ctrl) {
return h(
'main#insight',
h('div.' + (ctrl.vm.loading ? 'loading' : 'ready'), [
h('div.left-side', [
info(ctrl),
m('div.panel-tabs', [
m(
'a[data-panel="preset"]',
{
class: 'tab preset' + (ctrl.vm.panel === 'preset' ? ' active' : ''),
onclick: function () {
ctrl.setPanel('preset');
},
},
'Presets'
),
m(
'a[data-panel="filter"]',
{
class: 'tab filter' + (ctrl.vm.panel === 'filter' ? ' active' : ''),
onclick: function () {
ctrl.setPanel('filter');
},
},
'Filters'
),
h('div.panel-tabs', [
h('a.tab.preset', tabData(ctrl, 'preset'), 'Presets'),
h('a.tab.filter', tabData(ctrl, 'filter'), 'Filters'),
Object.keys(ctrl.vm.filters).length
? m(
? h(
'a.clear',
{
title: 'Clear all filters',
'data-icon': '',
onclick: ctrl.clearFilters,
attrs: {
title: 'Clear all filters',
'data-icon': '',
},
hook: bind('click', ctrl.clearFilters.bind(ctrl)),
},
'CLEAR'
)
@ -80,18 +61,8 @@ module.exports = function (ctrl) {
ctrl.vm.panel === 'preset' ? presets(ctrl) : null,
help(ctrl),
]),
m('header', [
axis(ctrl),
m(
'h2',
{
class: 'text',
'data-icon': '',
},
'Chess Insights'
),
]),
m('div.insight__main.box', renderMain(ctrl)),
]
h('header', [axis(ctrl), h('h2.text', { attrs: { 'data-icon': '' } }, 'Chess Insights')]),
thunk('div.insight__main.box', renderMain, [ctrl, cacheKey(ctrl)]),
])
);
};
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"numeral": ["./@types/numeral"]
}
},
"extends": "../tsconfig.base.json"
}