From 39c51a5ae64c59413e1699f2a1031361da80b00b Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Mon, 2 Aug 2021 18:26:17 -0700 Subject: [PATCH] Rewrite insights in typescript and snabbdom --- ui/@build/rollupProject/index.js | 3 + ui/insight/@types/numeral/index.d.ts | 5 + ui/insight/package.json | 7 +- ui/insight/rollup.config.mjs | 7 +- ui/insight/src/axis.ts | 123 ++++++------- ui/insight/src/boards.ts | 43 ++--- ui/insight/src/chart.ts | 114 +++++++----- ui/insight/src/ctrl.ts | 251 +++++++++++++-------------- ui/insight/src/filters.ts | 72 ++++---- ui/insight/src/global.d.ts | 71 ++++++++ ui/insight/src/help.ts | 19 +- ui/insight/src/info.ts | 78 +++++---- ui/insight/src/interfaces.ts | 112 ++++++++++++ ui/insight/src/main.ts | 33 ++-- ui/insight/src/presets.ts | 24 +-- ui/insight/src/table.ts | 65 ++++--- ui/insight/src/view.ts | 131 ++++++-------- ui/insight/tsconfig.json | 9 + 18 files changed, 681 insertions(+), 486 deletions(-) create mode 100644 ui/insight/@types/numeral/index.d.ts create mode 100644 ui/insight/src/global.d.ts create mode 100644 ui/insight/src/interfaces.ts create mode 100644 ui/insight/tsconfig.json diff --git a/ui/@build/rollupProject/index.js b/ui/@build/rollupProject/index.js index 5dbd1fca12..17f20512ed 100644 --- a/ui/@build/rollupProject/index.js +++ b/ui/@build/rollupProject/index.js @@ -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: [ diff --git a/ui/insight/@types/numeral/index.d.ts b/ui/insight/@types/numeral/index.d.ts new file mode 100644 index 0000000000..b1a9584c8f --- /dev/null +++ b/ui/insight/@types/numeral/index.d.ts @@ -0,0 +1,5 @@ +export default function numeral(n: number): Numeral; + +export interface Numeral { + format(f: string): string; +} diff --git a/ui/insight/package.json b/ui/insight/package.json index 4eb9ad5c59..3a01acdca4 100644 --- a/ui/insight/package.json +++ b/ui/insight/package.json @@ -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", diff --git a/ui/insight/rollup.config.mjs b/ui/insight/rollup.config.mjs index c4fe19e3c2..e528cb24db 100644 --- a/ui/insight/rollup.config.mjs +++ b/ui/insight/rollup.config.mjs @@ -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', + }, }, }); diff --git a/ui/insight/src/axis.ts b/ui/insight/src/axis.ts index fc591a662c..8add8c1edb 100644 --- a/ui/insight/src/axis.ts +++ b/ui/insight/src/axis.ts @@ -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 = + (callback: (item: T) => MaybeVNode) => + (categ: Categ) => + 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[^>]*>/, ''), + 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[^>]*>/, ''), - 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[^>]*>/, ''), - 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))) ), ]); -}; +} diff --git a/ui/insight/src/boards.ts b/ui/insight/src/boards.ts index b4a1dddb6c..dcd5f8edfa 100644 --- a/ui/insight/src/boards.ts +++ b/ui/insight/src/boards.ts @@ -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)), ]); -}; +} diff --git a/ui/insight/src/chart.ts b/ui/insight/src/chart.ts index 395611bd6a..33b7819ad7 100644 --- a/ui/insight/src/chart.ts +++ b/ui/insight/src/chart.ts @@ -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), + }, + }); +} diff --git a/ui/insight/src/ctrl.ts b/ui/insight/src/ctrl.ts index 65784d3eb7..dd45df073d 100644 --- a/ui/insight/src/ctrl.ts +++ b/ui/insight/src/ctrl.ts @@ -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(); -}; +} diff --git a/ui/insight/src/filters.ts b/ui/insight/src/filters.ts index f7be161fd3..b74810c96c 100644 --- a/ui/insight/src/filters.ts +++ b/ui/insight/src/filters.ts @@ -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))]); }) - ), - ]); -}; + ) + ); +} diff --git a/ui/insight/src/global.d.ts b/ui/insight/src/global.d.ts new file mode 100644 index 0000000000..0648b928c8 --- /dev/null +++ b/ui/insight/src/global.d.ts @@ -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; +} diff --git a/ui/insight/src/help.ts b/ui/insight/src/help.ts index 30e6293d03..8f8bf98629 100644 --- a/ui/insight/src/help.ts +++ b/ui/insight/src/help.ts @@ -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)]); }) ), ]); -}; +} diff --git a/ui/insight/src/info.ts b/ui/insight/src/info.ts index c768e224d3..d87b3dc1c6 100644 --- a/ui/insight/src/info.ts +++ b/ui/insight/src/info.ts @@ -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 + ), ]); -}; +} diff --git a/ui/insight/src/interfaces.ts b/ui/insight/src/interfaces.ts new file mode 100644 index 0000000000..e723953735 --- /dev/null +++ b/ui/insight/src/interfaces.ts @@ -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[]; + metricCategs: Categ[]; + presets: Preset[]; +} + +export interface Categ { + 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; +} diff --git a/ui/insight/src/main.ts b/ui/insight/src/main.ts index cd8c49a7e6..b8869c4660 100644 --- a/ui/insight/src/main.ts +++ b/ui/insight/src/main.ts @@ -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; +} diff --git a/ui/insight/src/presets.ts b/ui/insight/src/presets.ts index 377b1c908e..006e131f74 100644 --- a/ui/insight/src/presets.ts +++ b/ui/insight/src/presets.ts @@ -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 ); }) ); -}; +} diff --git a/ui/insight/src/table.ts b/ui/insight/src/table.ts index 1e21b7d277..fa5e2d240a 100644 --- a/ui/insight/src/table.ts +++ b/ui/insight/src/table.ts @@ -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])), + ]); + }) + ), + ]); +} diff --git a/ui/insight/src/view.ts b/ui/insight/src/view.ts index 38e0ceeb3c..8276446790 100644 --- a/ui/insight/src/view.ts +++ b/ui/insight/src/view.ts @@ -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)]), + ]) ); -}; +} diff --git a/ui/insight/tsconfig.json b/ui/insight/tsconfig.json new file mode 100644 index 0000000000..20bb45b2f8 --- /dev/null +++ b/ui/insight/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "numeral": ["./@types/numeral"] + } + }, + "extends": "../tsconfig.base.json" +}