diff --git a/package-lock.json b/package-lock.json index 141c34bdb..878e94e28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.2", + "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.3", "@types/node": "^18.15.11", "@types/webpack-env": "^1.18.2", @@ -3051,6 +3052,290 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -15290,6 +15575,259 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "dev": true + }, + "@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true + }, + "@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true + }, + "@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "dev": true + }, + "@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true + }, + "@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "requires": { + "@types/d3-dsv": "*" + } + }, + "@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true + }, + "@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true + }, + "@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "dev": true + }, + "@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true + }, + "@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true + }, + "@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true + }, + "@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dev": true, + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", + "dev": true + }, + "@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true + }, + "@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dev": true, + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "dev": true + }, + "@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true + }, + "@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", diff --git a/package.json b/package.json index 31c79dd1e..8bbbccbc6 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.2", + "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.3", "@types/node": "^18.15.11", "@types/webpack-env": "^1.18.2", diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 7ab386ea9..0345f4293 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -400,8 +400,6 @@ const modifyControlsStateViaTree = (state, tree, treeToo, colorings) => { * only when a file is dropped. (I've gone down too many rabbit holes in this PR to * do this now, unfortunately.) james, 2023 */ - state.coloringsPresentOnTree = new Set(); - state.coloringsPresentOnTreeWithConfidence = new Set(); // subset of above let coloringsToCheck = []; if (colorings) { diff --git a/src/actions/tree.js b/src/actions/tree.ts similarity index 71% rename from src/actions/tree.js rename to src/actions/tree.ts index a5f7de910..34967f0e4 100644 --- a/src/actions/tree.js +++ b/src/actions/tree.ts @@ -1,3 +1,4 @@ +import { AnyAction } from "@reduxjs/toolkit"; import { calcTipRadii } from "../util/tipRadiusHelpers"; import { strainNameToIdx, calculateVisiblityAndBranchThickness } from "../util/treeVisibilityHelpers"; import * as types from "./types"; @@ -10,7 +11,18 @@ import { createVisibleLegendValues, getLegendOrder } from "../util/colorScale"; import { getTraitFromNode } from "../util/treeMiscHelpers"; import { warningNotification } from "./notifications"; import { calcFullTipCounts, calcTipCounts } from "../util/treeCountingHelpers"; +import { PhyloNode } from "../components/tree/phyloTree/types"; +import { Metadata } from "../metadata"; +import { AppDispatch, RootState } from "../store"; +import { ReduxNode, TreeState } from "../reducers/tree/types"; +type RootIndex = number | undefined + +/** [root idx tree1, root idx tree2] */ +export type Root = [RootIndex, RootIndex] + +/** A function to be handled by redux (thunk) */ +type ThunkFunction = (dispatch: AppDispatch, getState: () => RootState) => void /** * Updates the `inView` property of nodes which depends on the currently selected @@ -18,10 +30,13 @@ import { calcFullTipCounts, calcTipCounts } from "../util/treeCountingHelpers"; * Note that this property is historically the remit of PhyloTree, however this function * may be called before those objects are created; in this case we store the property on * the tree node itself. - * @param {Int} idx - index of displayed root node - * @param {ReduxTreeState} tree */ -export const applyInViewNodesToTree = (idx, tree) => { +export const applyInViewNodesToTree = ( + /** index of displayed root node */ + idx: RootIndex, + + tree: TreeState, +): number => { const validIdxRoot = idx !== undefined ? idx : tree.idxOfInViewRootNode; if (tree.nodes[0].shell) { tree.nodes.forEach((d) => { @@ -29,12 +44,12 @@ export const applyInViewNodesToTree = (idx, tree) => { d.shell.update = true; }); if (tree.nodes[validIdxRoot].hasChildren) { - applyToChildren(tree.nodes[validIdxRoot].shell, (d) => {d.inView = true;}); + applyToChildren(tree.nodes[validIdxRoot].shell, (d: PhyloNode) => {d.inView = true;}); } else if (tree.nodes[validIdxRoot].parent.arrayIdx===0) { // subtree with n=1 tips => don't make the parent in-view as this will cover the entire tree! tree.nodes[validIdxRoot].shell.inView = true; } else { - applyToChildren(tree.nodes[validIdxRoot].parent.shell, (d) => {d.inView = true;}); + applyToChildren(tree.nodes[validIdxRoot].parent.shell, (d: PhyloNode) => {d.inView = true;}); } } else { /* FYI applyInViewNodesToTree is now setting inView on the redux nodes */ @@ -42,7 +57,7 @@ export const applyInViewNodesToTree = (idx, tree) => { d.inView = false; }); /* note that we cannot use `applyToChildren` as that operates on PhyloNodes */ - const _markChildrenInView = (node) => { + const _markChildrenInView = (node: ReduxNode) => { node.inView = true; if (node.children) { for (const child of node.children) _markChildrenInView(child); @@ -61,15 +76,21 @@ export const applyInViewNodesToTree = (idx, tree) => { * this fn relies on the "inView" attr of nodes * note that this function checks to see if the tree has been defined (different to if it's ready / loaded!) * for arg destructuring see https://simonsmith.io/destructuring-objects-as-function-parameters-in-es6/ - * @param {array|undefined} root Change the in-view part of the tree. [root idx tree1, root idx tree2]. - * [0, 0]: reset. [undefined, undefined]: do nothing - * @param {object | undefined} tipSelected - * @param {string | undefined} cladeSelected - * @return {function} a function to be handled by redux (thunk) */ -export const updateVisibleTipsAndBranchThicknesses = ( - {root = [undefined, undefined], cladeSelected = undefined} = {} -) => { +export const updateVisibleTipsAndBranchThicknesses = ({ + root = [undefined, undefined], + cladeSelected = undefined, +}: { + /** + * Change the in-view part of the tree. + * + * [0, 0]: reset. [undefined, undefined]: do nothing + */ + root?: Root + + cladeSelected?: string +} = {} +): ThunkFunction => { return (dispatch, getState) => { const { tree, treeToo, controls, frequencies } = getState(); if (root[0] === undefined && !cladeSelected && tree.selectedClade) { @@ -87,7 +108,7 @@ export const updateVisibleTipsAndBranchThicknesses = ( controls, {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric} ); - const dispatchObj = { + const dispatchObj: AnyAction = { type: types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS, visibility: data.visibility, visibilityVersion: data.visibilityVersion, @@ -142,11 +163,17 @@ export const updateVisibleTipsAndBranchThicknesses = ( * date changes need to update tip visibility & branch thicknesses * this can be done in a single action * NB calling this without specifying newMin OR newMax is a no-op - * @param {string|false} newMin optional - * @param {string|false} newMax optional - * @return {null} side-effects: a single action + * side-effects: a single action */ -export const changeDateFilter = ({newMin = false, newMax = false, quickdraw = false}) => { +export const changeDateFilter = ({ + newMin = false, + newMax = false, + quickdraw = false, +}: { + newMin?: string | false + newMax?: string | false + quickdraw?: boolean +}): ThunkFunction => { return (dispatch, getState) => { const { tree, treeToo, controls, frequencies } = getState(); if (!tree.nodes) {return;} @@ -155,7 +182,7 @@ export const changeDateFilter = ({newMin = false, newMax = false, quickdraw = fa dateMaxNumeric: newMax ? calendarToNumeric(newMax) : controls.dateMaxNumeric }; const data = calculateVisiblityAndBranchThickness(tree, controls, dates); - const dispatchObj = { + const dispatchObj: AnyAction = { type: types.CHANGE_DATES_VISIBILITY_THICKNESS, quickdraw, dateMin: newMin ? newMin : controls.dateMin, @@ -200,18 +227,28 @@ export const changeDateFilter = ({newMin = false, newMax = false, quickdraw = fa /** * NB all params are optional - supplying none resets the tip radii to defaults - * @param {string|number} selectedLegendItem value of the attr. if scale is continuous a bound will be used. - * @param {int} tipSelectedIdx the strain to highlight (always tree 1) - * @param {array} geoFilter a filter to apply to the strains. Empty array or array of len 2. [0]: geoResolution, [1]: value to filter to - * @return {null} side-effects: a single action + * side-effects: a single action */ export const updateTipRadii = ( - {tipSelectedIdx = false, selectedLegendItem = false, geoFilter = [], searchNodes = false} = {} -) => { + { + tipSelectedIdx = false, + selectedLegendItem = false, + geoFilter = [], + }: { + /** the strain to highlight (always tree 1) */ + tipSelectedIdx?: number | false, + + /** value of the attr. if scale is continuous a bound will be used. */ + selectedLegendItem?: string | number | false, + + /** a filter to apply to the strains. Empty array or array of len 2. [0]: geoResolution, [1]: value to filter to */ + geoFilter?: [string, string] | [], + } = {} +): ThunkFunction => { return (dispatch, getState) => { const { controls, tree, treeToo } = getState(); const colorScale = controls.colorScale; - const d = { + const d: AnyAction = { type: types.UPDATE_TIP_RADII, version: tree.tipRadiiVersion + 1 }; const tt = controls.showTreeToo; @@ -219,11 +256,11 @@ export const updateTipRadii = ( d.data = calcTipRadii({tipSelectedIdx, colorScale, tree}); if (tt) { const idx = strainNameToIdx(treeToo.nodes, tree.nodes[tipSelectedIdx].name); - d.dataToo = calcTipRadii({idx, colorScale, tree: treeToo}); + d.dataToo = calcTipRadii({tipSelectedIdx: idx, colorScale, tree: treeToo}); } } else { - d.data = calcTipRadii({selectedLegendItem, geoFilter, searchNodes, colorScale, tree}); - if (tt) d.dataToo = calcTipRadii({selectedLegendItem, geoFilter, searchNodes, colorScale, tree: treeToo}); + d.data = calcTipRadii({selectedLegendItem, geoFilter, colorScale, tree}); + if (tt) d.dataToo = calcTipRadii({selectedLegendItem, geoFilter, colorScale, tree: treeToo}); } dispatch(d); }; @@ -231,16 +268,22 @@ export const updateTipRadii = ( /** * Apply a filter to the current selection (i.e. filtered / "on" values associated with this trait) - * Explanation of the modes: - * "add" -> add the values to the current selection (if any exists). - * "inactivate" -> inactivate values (i.e. change active prop to false). To activate just use "add". - * "remove" -> remove the values from the current selection - * "set" -> set the values of the filter to be those provided. All disabled filters will be removed. XXX TODO. - * @param {string} mode allowed values: "set", "add", "remove" - * @param {string} trait the trait name of the filter ("authors", "country" etcetera) - * @param {Array of strings} values the values (see above) */ -export const applyFilter = (mode, trait, values) => { +export const applyFilter = ( + /** Explanation of the modes: + * - "add" -> add the values to the current selection (if any exists). + * - "inactivate" -> inactivate values (i.e. change active prop to false). To activate just use "add". + * - "remove" -> remove the values from the current selection + * - "set" -> set the values of the filter to be those provided. All disabled filters will be removed. XXX TODO. + */ + mode: "add" | "inactivate" | "remove" | "set", + + /** the trait name of the filter ("authors", "country" etcetera) */ + trait: string | symbol, + + /** the values (see above) */ + values: string[], +): ThunkFunction => { return (dispatch, getState) => { const { controls } = getState(); const currentlyFilteredTraits = Reflect.ownKeys(controls.filters); @@ -297,16 +340,15 @@ export const applyFilter = (mode, trait, values) => { }; }; -export const toggleTemporalConfidence = () => ({ +export const toggleTemporalConfidence = (): AnyAction => ({ type: types.TOGGLE_TEMPORAL_CONF }); /** * restore original state by iterating over all nodes and restoring children to unexplodedChildren (as necessary) - * @param {Array} nodes */ -const _resetExpodedTree = (nodes) => { +const _resetExpodedTree = (nodes: ReduxNode[]): void => { nodes.forEach((n) => { if (Object.prototype.hasOwnProperty.call(n, 'unexplodedChildren')) { n.children = n.unexplodedChildren; @@ -324,11 +366,17 @@ const _resetExpodedTree = (nodes) => { * create subtrees where branches have different attrs. * Note: because the children of a node may change, we store the previous (unexploded) children * as `unexplodedChildren` so we can return to the original tree. - * @param {Node} root - root node of entire tree - * @param {Node} node - current node being traversed - * @param {String} attr - trait name to determine if a child should become subtree */ -const _traverseAndCreateSubtrees = (root, node, attr) => { +const _traverseAndCreateSubtrees = ( + /** root node of entire tree */ + root: ReduxNode, + + /** current node being traversed */ + node: ReduxNode, + + /** trait name to determine if a child should become subtree */ + attr: string, +): void => { // store original children so we traverse the entire tree const originalChildren = node.hasChildren ? [...node.children] : []; @@ -347,7 +395,7 @@ const _traverseAndCreateSubtrees = (root, node, attr) => { subtreeRootNode.parent = root; }); node.unexplodedChildren = originalChildren; - node.children = node.children.filter((c, idx) => { + node.children = node.children.filter((_c, idx) => { return !childrenToPrune.includes(idx); }); /* it may be the case that the node now has no children (they're all subtrees!) */ @@ -364,7 +412,11 @@ const _traverseAndCreateSubtrees = (root, node, attr) => { /** * sort the subtrees by the order the trait would appear in the legend */ -const _orderSubtrees = (metadata, nodes, attr) => { +const _orderSubtrees = ( + metadata: Metadata, + nodes: ReduxNode[], + attr: string, +): void => { const attrValueOrder = getLegendOrder(attr, metadata.colorings[attr], nodes, undefined); nodes[0].children.sort((childA, childB) => { const [attrA, attrB] = [getTraitFromNode(childA, attr), getTraitFromNode(childB, attr)]; @@ -381,43 +433,47 @@ const _orderSubtrees = (metadata, nodes, attr) => { }); }; -export const explodeTree = (attr) => (dispatch, getState) => { - const {tree, metadata, controls} = getState(); - _resetExpodedTree(tree.nodes); // ensure we start with an unexploded tree - if (attr) { - const root = tree.nodes[0]; - _traverseAndCreateSubtrees(root, root, attr); - if (root.unexplodedChildren.length === root.children.length) { - dispatch(warningNotification({message: "Cannot explode tree on this trait - is it defined on internal nodes?"})); - return; +export const explodeTree = ( + attr: string | undefined, +): ThunkFunction => { + return (dispatch, getState) => { + const {tree, metadata, controls} = getState(); + _resetExpodedTree(tree.nodes); // ensure we start with an unexploded tree + if (attr) { + const root = tree.nodes[0]; + _traverseAndCreateSubtrees(root, root, attr); + if (root.unexplodedChildren.length === root.children.length) { + dispatch(warningNotification({message: "Cannot explode tree on this trait - is it defined on internal nodes?"})); + return; + } + _orderSubtrees(metadata, tree.nodes, attr); } - _orderSubtrees(metadata, tree.nodes, attr); - } - /* tree splitting necessitates recalculation of tip counts */ - calcFullTipCounts(tree.nodes[0]); - calcTipCounts(tree.nodes[0], tree.visibility); - /* we default to zooming out completely whenever we explode the tree. There are nicer behaviours here, - such as re-calculating the MRCA of visible nodes, but this comes at the cost of increased complexity. - Note that the functions called here involve a lot of code duplication and are good targets for refactoring */ - applyInViewNodesToTree(0, tree); - const visData = calculateVisiblityAndBranchThickness( - tree, - controls, - {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric} - ); - visData.idxOfInViewRootNode = 0; - /* Changes in visibility require a recomputation of which legend items we wish to display */ - visData.visibleLegendValues = createVisibleLegendValues({ - colorBy: controls.colorBy, - genotype: controls.colorScale.genotype, - scaleType: controls.colorScale.scaleType, - legendValues: controls.colorScale.legendValues, - treeNodes: tree.nodes, - visibility: visData.visibility - }); - dispatch({ - type: types.CHANGE_EXPLODE_ATTR, - explodeAttr: attr, - ...visData - }); + /* tree splitting necessitates recalculation of tip counts */ + calcFullTipCounts(tree.nodes[0]); + calcTipCounts(tree.nodes[0], tree.visibility); + /* we default to zooming out completely whenever we explode the tree. There are nicer behaviours here, + such as re-calculating the MRCA of visible nodes, but this comes at the cost of increased complexity. + Note that the functions called here involve a lot of code duplication and are good targets for refactoring */ + applyInViewNodesToTree(0, tree); + const visData = calculateVisiblityAndBranchThickness( + tree, + controls, + {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric} + ); + visData.idxOfInViewRootNode = 0; + /* Changes in visibility require a recomputation of which legend items we wish to display */ + visData.visibleLegendValues = createVisibleLegendValues({ + colorBy: controls.colorBy, + genotype: controls.colorScale.genotype, + scaleType: controls.colorScale.scaleType, + legendValues: controls.colorScale.legendValues, + treeNodes: tree.nodes, + visibility: visData.visibility + }); + dispatch({ + type: types.CHANGE_EXPLODE_ATTR, + explodeAttr: attr, + ...visData + }); + }; }; diff --git a/src/components/controls/toggle-focus.tsx b/src/components/controls/toggle-focus.tsx index 2f82f87ae..c48fadf93 100644 --- a/src/components/controls/toggle-focus.tsx +++ b/src/components/controls/toggle-focus.tsx @@ -1,18 +1,18 @@ import React from "react"; import { connect } from "react-redux"; import { FaInfoCircle } from "react-icons/fa"; -import { Dispatch } from "@reduxjs/toolkit"; import Toggle from "./toggle"; import { SidebarIconContainer, StyledTooltip } from "./styles"; import { TOGGLE_FOCUS } from "../../actions/types"; -import { RootState } from "../../store"; +import { Layout } from "../../reducers/controls"; +import { AppDispatch, RootState } from "../../store"; function ToggleFocus({ tooltip, focus, layout, dispatch, mobileDisplay }: { tooltip: React.ReactElement; focus: boolean; - layout: "rect" | "radial" | "unrooted" | "clock" | "scatter"; - dispatch: Dispatch; + layout: Layout; + dispatch: AppDispatch; mobileDisplay: boolean; }) { // Focus functionality is only available to layouts that have the concept of a unitless y-axis diff --git a/src/components/tree/index.ts b/src/components/tree/index.ts index 9f0cc1d2c..10de934bc 100644 --- a/src/components/tree/index.ts +++ b/src/components/tree/index.ts @@ -1,8 +1,11 @@ -import { connect } from "react-redux"; +import { connect, MapStateToProps } from "react-redux"; import UnconnectedTree from "./tree"; import { RootState } from "../../store"; +import { TreeComponentOwnProps, TreeComponentStateProps } from "./types"; -const Tree = connect((state: RootState) => ({ +const mapStateToProps: MapStateToProps = ( + state: RootState, +): TreeComponentStateProps => ({ tree: state.tree, treeToo: state.treeToo, selectedNode: state.controls.selectedNode, @@ -32,6 +35,8 @@ const Tree = connect((state: RootState) => ({ animationPlayPauseButton: state.controls.animationPlayPauseButton, showOnlyPanels: state.controls.showOnlyPanels, performanceFlags: state.controls.performanceFlags, -}))(UnconnectedTree); +}); + +const Tree = connect(mapStateToProps)(UnconnectedTree); export default Tree; diff --git a/src/components/tree/phyloTree/change.js b/src/components/tree/phyloTree/change.ts similarity index 71% rename from src/components/tree/phyloTree/change.js rename to src/components/tree/phyloTree/change.ts index 1a0b6bedc..0c586f7df 100644 --- a/src/components/tree/phyloTree/change.js +++ b/src/components/tree/phyloTree/change.ts @@ -1,3 +1,4 @@ +import { Selection, Transition } from "d3"; import { timerFlush } from "d3-timer"; import { calcConfidenceWidth } from "./confidence"; import { applyToChildren, setDisplayOrder } from "./helpers"; @@ -6,11 +7,15 @@ import { NODE_VISIBLE } from "../../../util/globals"; import { getBranchVisibility, strokeForBranch } from "./renderers"; import { shouldDisplayTemporalConfidence } from "../../../reducers/controls"; import { makeTipLabelFunc } from "./labels"; +import { ChangeParams, PhyloNode, PhyloTreeType, PropsForPhyloNodes, SVGProperty, TreeElement } from "./types"; /* loop through the nodes and update each provided prop with the new value * additionally, set d.update -> whether or not the node props changed */ -const updateNodesWithNewData = (nodes, newNodeProps) => { +const updateNodesWithNewData = ( + nodes: PhyloNode[], + newNodeProps: PropsForPhyloNodes, +): void => { // console.log("update nodes with data for these keys:", Object.keys(newNodeProps)); // let tmp = 0; nodes.forEach((d, i) => { @@ -35,72 +40,88 @@ const updateNodesWithNewData = (nodes, newNodeProps) => { const svgSetters = { attrs: { ".tip": { - r: (d) => d.r, - cx: (d) => d.xTip, - cy: (d) => d.yTip + r: (d: PhyloNode) => d.r, + cx: (d: PhyloNode) => d.xTip, + cy: (d: PhyloNode) => d.yTip }, ".branch": { }, ".vaccineCross": { - d: (d) => d.vaccineCross + d: (d: PhyloNode) => d.vaccineCross }, ".conf": { - d: (d) => d.confLine + d: (d: PhyloNode) => d.confLine } }, styles: { ".tip": { - fill: (d) => d.fill, - stroke: (d) => d.tipStroke, - visibility: (d) => d.visibility === NODE_VISIBLE ? "visible" : "hidden" + fill: (d: PhyloNode) => d.fill, + stroke: (d: PhyloNode) => d.tipStroke, + visibility: (d: PhyloNode) => d.visibility === NODE_VISIBLE ? "visible" : "hidden" }, ".conf": { - stroke: (d) => d.branchStroke, + stroke: (d: PhyloNode) => d.branchStroke, "stroke-width": calcConfidenceWidth }, // only allow stroke to be set on individual branches ".branch": { - "stroke-width": (d) => d["stroke-width"] + "px", // style - as per drawBranches() - stroke: (d) => strokeForBranch(d), // TODO: revisit if we bring back SVG gradients - cursor: (d) => d.visibility === NODE_VISIBLE ? "pointer" : "default", + "stroke-width": (d: PhyloNode) => d["stroke-width"] + "px", // style - as per drawBranches() + stroke: (d: PhyloNode) => strokeForBranch(d), // TODO: revisit if we bring back SVG gradients + cursor: (d: PhyloNode) => d.visibility === NODE_VISIBLE ? "pointer" : "default", visibility: getBranchVisibility } } }; +type SelectionOrTransition = + Selection | + Transition + +type UpdateCall = (selectionOrTransition: SelectionOrTransition) => void; + + /** createUpdateCall * returns a function which can be called as part of a D3 chain in order to modify * the SVG elements. * svgSetters (see above) are used to actually modify the property on the element, * so the given property must also be present there! - * @param {string} treeElem (e.g. ".tip" or ".branch") - * @param {list} properties (e.g. ["visibiliy", "stroke-width"]) - * @return {function} used in a d3 selection, i.e. d3.selection().methods().call(X) */ -const createUpdateCall = (treeElem, properties) => (selection) => { - // First: the properties to update via d3Selection.attr call - if (svgSetters.attrs[treeElem]) { - [...properties].filter((x) => svgSetters.attrs[treeElem][x]) - .forEach((attrName) => { - // console.log(`applying attr ${attrName} to ${treeElem}`) - selection.attr(attrName, svgSetters.attrs[treeElem][attrName]); - }); - } - // Second: the properties to update via d3Selection.style call - if (svgSetters.styles[treeElem]) { - [...properties].filter((x) => svgSetters.styles[treeElem][x]) - .forEach((styleName) => { - // console.log(`applying style ${styleName} to ${treeElem}`) - selection.style(styleName, svgSetters.styles[treeElem][styleName]); - }); - } -}; +function createUpdateCall( + treeElem: TreeElement, -const genericSelectAndModify = (svg, treeElem, updateCall, transitionTime) => { + /** e.g. ["visibility", "stroke-width"] */ + properties: Set, +): UpdateCall { + return (selection) => { + // First: the properties to update via d3Selection.attr call + if (svgSetters.attrs[treeElem]) { + [...properties].filter((x) => svgSetters.attrs[treeElem][x]) + .forEach((attrName) => { + // console.log(`applying attr ${attrName} to ${treeElem}`) + selection.attr(attrName, svgSetters.attrs[treeElem][attrName]); + }); + } + // Second: the properties to update via d3Selection.style call + if (svgSetters.styles[treeElem]) { + [...properties].filter((x) => svgSetters.styles[treeElem][x]) + .forEach((styleName) => { + // console.log(`applying style ${styleName} to ${treeElem}`) + selection.style(styleName, svgSetters.styles[treeElem][styleName]); + }); + } + }; +} + +const genericSelectAndModify = ( + svg: Selection, + treeElem: TreeElement, + updateCall: UpdateCall, + transitionTime: number, +): void => { // console.log("general svg update for", treeElem); - let selection = svg.selectAll(treeElem) - .filter((d) => d.update); + let selection: SelectionOrTransition = svg.selectAll(treeElem) + .filter((d: PhyloNode) => d.update); if (transitionTime) { selection = selection.transition().duration(transitionTime); } @@ -113,14 +134,20 @@ const genericSelectAndModify = (svg, treeElem, updateCall, transitionTime) => { * @transitionTime {INT} - in ms. if 0 then no transition (timerFlush is used) * @extras {dict} - extra keywords to tell this function to call certain phyloTree update methods. In flux. */ -export const modifySVG = function modifySVG(elemsToUpdate, svgPropsToUpdate, transitionTime, extras) { - let updateCall; - const classesToPotentiallyUpdate = [".tip", ".vaccineDottedLine", ".vaccineCross", ".branch"]; /* order is respected */ +export const modifySVG = function modifySVG( + this: PhyloTreeType, + elemsToUpdate: Set, + svgPropsToUpdate: Set, + transitionTime: number, + extras: Extras, +): void { + let updateCall: UpdateCall; + const classesToPotentiallyUpdate: TreeElement[] = [".tip", ".vaccineDottedLine", ".vaccineCross", ".branch"]; /* order is respected */ /* treat stem / branch specially, but use these to replace a normal .branch call if that's also to be applied */ if (elemsToUpdate.has(".branch.S") || elemsToUpdate.has(".branch.T")) { const applyBranchPropsAlso = elemsToUpdate.has(".branch"); if (applyBranchPropsAlso) classesToPotentiallyUpdate.splice(classesToPotentiallyUpdate.indexOf(".branch"), 1); - const ST = [".S", ".T"]; + const ST: Array<".S" | ".T"> = [".S", ".T"]; ST.forEach((x, STidx) => { if (elemsToUpdate.has(`.branch${x}`)) { if (applyBranchPropsAlso) { @@ -196,7 +223,14 @@ export const modifySVG = function modifySVG(elemsToUpdate, svgPropsToUpdate, tra * step 2: when step 1 has finished, move tips across the screen. * step 3: when step 2 has finished, redraw everything. No transition here. */ -export const modifySVGInStages = function modifySVGInStages(elemsToUpdate, svgPropsToUpdate, transitionTimeFadeOut, transitionTimeMoveTips, extras) { +export const modifySVGInStages = function modifySVGInStages( + this: PhyloTreeType, + elemsToUpdate: Set, + svgPropsToUpdate: Set, + transitionTimeFadeOut: number, + transitionTimeMoveTips: number, + extras: Extras, +): void { elemsToUpdate.delete(".tip"); this.hideGrid(); let inProgress = 0; /* counter of transitions currently in progress */ @@ -236,46 +270,55 @@ export const modifySVGInStages = function modifySVGInStages(elemsToUpdate, svgPr }; +interface Extras { + removeConfidences: boolean + showConfidences: boolean + newBranchLabellingKey?: string + + timeSliceHasPotentiallyChanged?: boolean + hideTipLabels?: boolean +} + + /* the main interface to changing a currently rendered tree. * simply call change and tell it what should be changed. * try to do a single change() call with as many things as possible in it */ -export const change = function change({ - /* booleans for what should be changed */ - changeColorBy = false, - changeVisibility = false, - changeTipRadii = false, - changeBranchThickness = false, - showConfidences = false, - removeConfidences = false, - zoomIntoClade = false, - svgHasChangedDimensions = false, - animationInProgress = false, - changeNodeOrder = false, - /* change these things to provided value (unless undefined) */ - newDistance = undefined, - newLayout = undefined, - updateLayout = undefined, // todo - this seems identical to `newLayout` - newBranchLabellingKey = undefined, - showAllBranchLabels = undefined, - newTipLabelKey = undefined, - /* arrays of data (the same length as nodes) */ - branchStroke = undefined, - tipStroke = undefined, - fill = undefined, - visibility = undefined, - tipRadii = undefined, - branchThickness = undefined, - /* other data */ - focus = undefined, - scatterVariables = undefined, - performanceFlags = {}, -}) { +export const change = function change( + this: PhyloTreeType, + { + changeColorBy = false, + changeVisibility = false, + changeTipRadii = false, + changeBranchThickness = false, + showConfidences = false, + removeConfidences = false, + zoomIntoClade = false, + svgHasChangedDimensions = false, + animationInProgress = false, + changeNodeOrder = false, + focus = false, + newDistance = undefined, + newLayout = undefined, + updateLayout = undefined, + newBranchLabellingKey = undefined, + showAllBranchLabels = undefined, + newTipLabelKey = undefined, + branchStroke = undefined, + tipStroke = undefined, + fill = undefined, + visibility = undefined, + tipRadii = undefined, + branchThickness = undefined, + scatterVariables = undefined, + performanceFlags = undefined, + }: ChangeParams +): void { // console.log("\n** phylotree.change() (time since last run:", Date.now() - this.timeLastRenderRequested, "ms) **\n\n"); timerStart("phylotree.change()"); - const elemsToUpdate = new Set(); /* what needs updating? E.g. ".branch", ".tip" etc */ - const nodePropsToModify = {}; /* which properties (keys) on the nodes should be updated (before the SVG) */ - const svgPropsToUpdate = new Set(); /* which SVG properties shall be changed. E.g. "fill", "stroke" */ + const elemsToUpdate = new Set(); /* what needs updating? E.g. ".branch", ".tip" etc */ + const nodePropsToModify: PropsForPhyloNodes = {}; /* which properties (keys) on the nodes should be updated (before the SVG) */ + const svgPropsToUpdate = new Set(); /* which SVG properties shall be changed. E.g. "fill", "stroke" */ const useModifySVGInStages = newLayout; /* use modifySVGInStages rather than modifySVG. Not used often. */ @@ -350,7 +393,7 @@ export const change = function change({ this.zoomNode = zoomIntoClade.n.hasChildren ? zoomIntoClade : zoomIntoClade.n.parent.shell; - applyToChildren(this.zoomNode, (d) => {d.inView = true;}); + applyToChildren(this.zoomNode, (d: PhyloNode) => {d.inView = true;}); } if (svgHasChangedDimensions || changeNodeOrder) { this.nodes.forEach((d) => {d.update = true;}); @@ -375,7 +418,7 @@ export const change = function change({ } /* mapToScreen */ if ( - svgPropsToUpdate.has(["stroke-width"]) || + svgPropsToUpdate.has("stroke-width") || newDistance || newLayout || changeNodeOrder || @@ -392,8 +435,8 @@ export const change = function change({ elemsToUpdate.add('.tipLabel'); /* will trigger d3 commands as required */ } - const extras = { removeConfidences, showConfidences, newBranchLabellingKey }; - extras.timeSliceHasPotentiallyChanged = changeVisibility || newDistance; + const extras: Extras = { removeConfidences, showConfidences, newBranchLabellingKey }; + extras.timeSliceHasPotentiallyChanged = changeVisibility || newDistance !== undefined; extras.hideTipLabels = animationInProgress || newTipLabelKey === 'none'; if (useModifySVGInStages) { this.modifySVGInStages(elemsToUpdate, svgPropsToUpdate, transitionTime, 1000, extras); diff --git a/src/components/tree/phyloTree/defaultParams.js b/src/components/tree/phyloTree/defaultParams.ts similarity index 92% rename from src/components/tree/phyloTree/defaultParams.js rename to src/components/tree/phyloTree/defaultParams.ts index b758e4cb0..beba4a81a 100644 --- a/src/components/tree/phyloTree/defaultParams.js +++ b/src/components/tree/phyloTree/defaultParams.ts @@ -1,6 +1,7 @@ import { dataFont, darkGrey } from "../../../globalStyles"; +import { Params } from "./types"; -export const createDefaultParams = () => ({ +export const createDefaultParams = (): Params => ({ regressionStroke: darkGrey, regressionWidth: 6, majorGridStroke: "#DDD", diff --git a/src/components/tree/phyloTree/helpers.js b/src/components/tree/phyloTree/helpers.ts similarity index 84% rename from src/components/tree/phyloTree/helpers.js rename to src/components/tree/phyloTree/helpers.ts index 82a13684e..9ff1417d8 100644 --- a/src/components/tree/phyloTree/helpers.js +++ b/src/components/tree/phyloTree/helpers.ts @@ -3,11 +3,16 @@ import { max } from "d3-array"; import {getTraitFromNode, getDivFromNode, getBranchMutations} from "../../../util/treeMiscHelpers"; import { NODE_VISIBLE } from "../../../util/globals"; import { timerStart, timerEnd } from "../../../util/perf"; +import { ReduxNode } from "../../../reducers/tree/types"; +import { Distance, PhyloNode } from "./types"; /** get a string to be used as the DOM element ID * Note that this cannot have any "special" characters */ -export const getDomId = (type, strain) => { +export const getDomId = ( + type: string, + strain: string, +): string => { // Replace non-alphanumeric characters with dashes (probably unnecessary) const name = `${type}_${strain}`.replace(/(\W+)/g, '-'); return CSS.escape(name); @@ -16,10 +21,13 @@ export const getDomId = (type, strain) => { /** * this function takes a call back and applies it recursively * to all child nodes, including internal nodes - * @param {PhyloNode} node - * @param {Function} func - function to apply to each children. Is passed a single argument, the of the children. */ -export const applyToChildren = (phyloNode, func) => { +export const applyToChildren = ( + phyloNode: PhyloNode, + + /** function to apply to each child. Is passed a single argument, the of the children. */ + func: (node: PhyloNode) => void, +): void => { func(phyloNode); const node = phyloNode.n; if ((!node.hasChildren) || (node.children === undefined)) { // in case clade set by URL, terminal hasn't been set yet! @@ -34,13 +42,14 @@ export const applyToChildren = (phyloNode, func) => { * Calculates the display order of all nodes, which corresponds to the vertical position * of nodes in a rectangular tree. * If `yCounter` is undefined then we wish to hide the node and all descendants of it - * @param {PhyloNode} node - * @param {function} incrementer - * @param {int|undefined} yCounter * @sideeffect modifies node.displayOrder and node.displayOrderRange - * @returns {int|undefined} current yCounter after assignment to the tree originating from `node` + * Returns the current yCounter after assignment to the tree originating from `node` */ -export const setDisplayOrderRecursively = (node, incrementer, yCounter) => { +export const setDisplayOrderRecursively = ( + node: PhyloNode, + incrementer: (node: PhyloNode) => number, + yCounter?: number, +): number | undefined => { const children = node.n.children; // (redux) tree node if (children && children.length) { for (let i = children.length - 1; i >= 0; i--) { @@ -65,7 +74,10 @@ export const setDisplayOrderRecursively = (node, incrementer, yCounter) => { * the returned value is to be interpreted as a count of the number of tips that would * otherwise fit in the gap */ -function _getSpaceBetweenSubtrees(numSubtrees, numTips) { +function _getSpaceBetweenSubtrees( + numSubtrees: number, + numTips: number, +): number { if (numSubtrees===1 || numTips<10) { return 0; } @@ -83,12 +95,14 @@ function _getSpaceBetweenSubtrees(numSubtrees, numTips) { * PhyloTree can subsequently use this information. Accessed by prototypes * rectangularLayout, radialLayout, createChildrenAndParents * side effects: .displayOrder (i.e. in the redux node) and .displayOrderRange - * @param {Object} props - * @param {Array} props.nodes - * @param {boolean} props.focus - * @returns {undefined} */ -export const setDisplayOrder = ({nodes, focus}) => { +export const setDisplayOrder = ({ + nodes, + focus, +}: { + nodes: PhyloNode[] + focus: boolean +}): void => { timerStart("setDisplayOrder"); const numSubtrees = nodes[0].n.children.filter((n) => n.fullTipCount!==0).length; @@ -143,7 +157,7 @@ export const setDisplayOrder = ({nodes, focus}) => { }; -export const formatDivergence = (divergence) => { +export const formatDivergence = (divergence: number): string | number => { return divergence > 1 ? Math.round((divergence + Number.EPSILON) * 1000) / 1000 : divergence > 0.01 ? @@ -156,7 +170,7 @@ export const formatDivergence = (divergence) => { * This differs depending on which tree is in view so it's helpful to access it * by reaching into phyotree to get it */ -export const getIdxOfInViewRootNode = (node) => { +export const getIdxOfInViewRootNode = (node: ReduxNode): number => { return node.shell.that.zoomNode.n.arrayIdx; }; @@ -164,7 +178,11 @@ export const getIdxOfInViewRootNode = (node) => { * Are the provided nodes within some divergence / time of each other? * NOTE: `otherNode` is always closer to the root in the tree than `node` */ -function isWithinBranchTolerance(node, otherNode, distanceMeasure) { +function isWithinBranchTolerance( + node: ReduxNode, + otherNode: ReduxNode, + distanceMeasure: Distance, +): boolean { if (distanceMeasure === "num_date") { /* We calculate the threshold by reaching into phylotree to extract the date range of the dataset and then split the data into ~50 slices. This could be refactored to not reach into phylotree. */ @@ -183,7 +201,7 @@ function isWithinBranchTolerance(node, otherNode, distanceMeasure) { * Walk up the tree from node until we find either a node which has a nucleotide mutation or we * reach the root of the (sub)tree. Gaps, deletions and undeletions do not count as mutations here. */ -function findFirstBranchWithAMutation(node) { +function findFirstBranchWithAMutation(node: ReduxNode): ReduxNode { if (node.parent === node) { return node; } @@ -200,13 +218,12 @@ function findFirstBranchWithAMutation(node) { * branch length threshold (either divergence or time). This is useful for finding the node * beyond a polytomy, or polytomy-like structure. If nucleotide mutations are defined on * the tree (and distanceMeasure=div) then we find the first branch with a mutation. - * @param {object} node - tree node - * @param {string} distanceMeasure -- 'num_date' or 'div' - * @param {object} observedMutations - * @returns {object} the closest node up the tree (towards the root) which is beyond - * some threshold */ -export const getParentBeyondPolytomy = (node, distanceMeasure, observedMutations) => { +export const getParentBeyondPolytomy = ( + node: ReduxNode, + distanceMeasure: Distance, + observedMutations: Record, +): ReduxNode => { let potentialNode = node.parent; if (distanceMeasure==="div" && areNucleotideMutationsPresent(observedMutations)) { return findFirstBranchWithAMutation(node); @@ -235,7 +252,9 @@ function areNucleotideMutationsPresent(observedMutations) { * we will "guess" this here. A future augur update will export this in a JSON key, * removing the need to guess. */ -export function guessAreMutationsPerSite(scale) { +export function guessAreMutationsPerSite( + scale: d3.ScaleContinuousNumeric, +): boolean { const maxDivergence = max(scale.domain()); return maxDivergence <= 5; } @@ -243,19 +262,15 @@ export function guessAreMutationsPerSite(scale) { /** * Is the node a subtree root node? (implies that we have either exploded trees or * the dataset has multiple subtrees to display) - * @param {ReduxTreeNode} n - * @returns {bool} */ -const isSubtreeRoot = (n) => (n.parent.name === "__ROOT" && n.parentInfo.original); +const isSubtreeRoot = (n: ReduxNode): boolean => (n.parent.name === "__ROOT" && n.parentInfo.original !== undefined); /** * Gets the parent node to be used for stem / branch calculation. * Most of the time this is the same as `d.n.parent` however it is not in the * case of the root nodes for subtrees (e.g. exploded trees). - * @param {Node} n - * @returns {Node} */ -export const stemParent = (n) => { +export const stemParent = (n: ReduxNode): ReduxNode => { return isSubtreeRoot(n) ? n.parentInfo.original : n.parent; }; @@ -265,13 +280,13 @@ export const stemParent = (n) => { * This is not strictly the same as the `displayOrder` the scatterplot axis * renders increasing values going upwards (i.e. from the bottom to top of screen) * whereas the rectangular tree renders zero at the top and goes downwards - * @param {Array} nodes - * @returns {function} which takes a single argument of type */ -export const nodeOrdering = (nodes) => { +export const nodeOrdering = ( + nodes: PhyloNode[], +): ((d: PhyloNode) => [number, number]) => { const maxVal = nodes.map((d) => d.displayOrder) .reduce((acc, val) => ((val ?? 0) > acc ? val : acc), 0); - return (d) => ([ + return (d: PhyloNode) => ([ maxVal - d.displayOrder, isSubtreeRoot(d.n) ? undefined : (maxVal - d.n.parent.shell.displayOrder) ]); diff --git a/src/components/tree/phyloTree/layouts.js b/src/components/tree/phyloTree/layouts.ts similarity index 90% rename from src/components/tree/phyloTree/layouts.js rename to src/components/tree/phyloTree/layouts.ts index 226ae2352..3ddeaba2c 100644 --- a/src/components/tree/phyloTree/layouts.js +++ b/src/components/tree/phyloTree/layouts.ts @@ -1,20 +1,23 @@ /* eslint-disable no-multi-spaces */ /* eslint-disable space-infix-ops */ -import { min, max } from "d3-array"; -import scaleLinear from "d3-scale/src/linear"; -import {point as scalePoint} from "d3-scale/src/band"; +import { scaleLinear, scalePoint } from "d3-scale"; import { timerStart, timerEnd } from "../../../util/perf"; import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers"; import { stemParent, nodeOrdering } from "./helpers"; import { numDate } from "../../../util/colorHelpers"; +import { Layout, ScatterVariables } from "../../../reducers/controls"; +import { ReduxNode } from "../../../reducers/tree/types"; +import { Distance, Params, PhyloNode, PhyloTreeType } from "./types"; /** * assigns the attribute this.layout and calls the function that * calculates the x,y coordinates for the respective layouts - * @param layout -- the layout to be used, has to be one of - * ["rect", "radial", "unrooted", "clock", "scatter"] */ -export const setLayout = function setLayout(layout, scatterVariables) { +export const setLayout = function setLayout( + this: PhyloTreeType, + layout?: Layout, + scatterVariables?: ScatterVariables, +): void { // console.log("set layout"); timerStart("setLayout"); if (typeof layout === "undefined" || layout !== this.layout) { @@ -52,9 +55,8 @@ export const setLayout = function setLayout(layout, scatterVariables) { /** * assignes x,y coordinates for a rectangular layout - * @return {null} */ -export const rectangularLayout = function rectangularLayout() { +export const rectangularLayout = function rectangularLayout(this: PhyloTreeType): void { this.nodes.forEach((d) => { d.y = d.displayOrder; // precomputed y-values d.x = d.depth; // depth according to current distance @@ -74,7 +76,7 @@ export const rectangularLayout = function rectangularLayout() { * assign x,y coordinates for nodes based upon user-selected variables * TODO: timeVsRootToTip is a specific instance of this */ -export const scatterplotLayout = function scatterplotLayout() { +export const scatterplotLayout = function scatterplotLayout(this: PhyloTreeType): void { if (!this.scatterVariables) { console.error("Scatterplot called without variables"); return; @@ -84,7 +86,7 @@ export const scatterplotLayout = function scatterplotLayout() { nodeOrdering(this.nodes) : undefined; - this.nodes.forEach((d) => { + for (const d of this.nodes) { // set x and parent X values if (this.scatterVariables.x==="div") { d.x = getDivFromNode(d.n); @@ -98,7 +100,7 @@ export const scatterplotLayout = function scatterplotLayout() { d.x = getTraitFromNode(d.n, this.scatterVariables.x); d.px = getTraitFromNode(stemParent(d.n), this.scatterVariables.x); if (this.scatterVariables.xTemporal) { - [d.x, d.px] = [numDate(d.x, true), numDate(d.px, true)] + [d.x, d.px] = [numDate(d.x), numDate(d.px)] } } // set y and parent values @@ -114,10 +116,10 @@ export const scatterplotLayout = function scatterplotLayout() { d.y = getTraitFromNode(d.n, this.scatterVariables.y); d.py = getTraitFromNode(stemParent(d.n), this.scatterVariables.y); if (this.scatterVariables.yTemporal) { - [d.y, d.py] = [numDate(d.y, true), numDate(d.py, true)] + [d.y, d.py] = [numDate(d.y), numDate(d.py)] } } - }); + } if (this.vaccines) { /* overlay vaccine cross on tip */ this.vaccines.forEach((d) => { @@ -135,17 +137,18 @@ export const scatterplotLayout = function scatterplotLayout() { /** * Utility function for the unrooted tree layout. See `unrootedLayout` for details. - * @param {PhyloNode} node - * @param {number} totalLeafWeight */ -const unrootedPlaceSubtree = (node, totalLeafWeight) => { +const unrootedPlaceSubtree = ( + node: PhyloNode, + totalLeafWeight: number, +): void => { const branchLength = node.depth - node.pDepth; node.x = node.px + branchLength * Math.cos(node.tau + node.w * 0.5); node.y = node.py + branchLength * Math.sin(node.tau + node.w * 0.5); let eta = node.tau; // eta is the cumulative angle for the wedges in the layout if (node.n.hasChildren) { for (let i = 0; i < node.n.children.length; i++) { - const ch = node.n.children[i].shell; // ch is a + const ch = node.n.children[i].shell; ch.w = 2 * Math.PI * leafWeight(ch.n) / totalLeafWeight; ch.tau = eta; eta += ch.w; @@ -164,9 +167,8 @@ const unrootedPlaceSubtree = (node, totalLeafWeight) => { /** * calculates x,y coordinates for the unrooted layout. this is * done recursively via a the function unrootedPlaceSubtree - * @return {null} */ -export const unrootedLayout = function unrootedLayout() { +export const unrootedLayout = function unrootedLayout(this: PhyloTreeType): void { /* the angle of a branch (i.e. the line leading to the node) is `tau + 0.5*w` `tau` stores the previous angle which has been used `w` is a measurement of the angle occupied by the clade defined by this node @@ -214,9 +216,8 @@ export const unrootedLayout = function unrootedLayout() { * calculates and assigns x,y coordinates for the radial layout. * in addition to x,y, this calculates the end-points of the radial * arcs and whether that arc is more than pi or not - * @return {null} */ -export const radialLayout = function radialLayout() { +export const radialLayout = function radialLayout(this: PhyloTreeType): void { const maxDisplayOrder = Math.max(...this.nodes.map((d) => d.displayOrder).filter((val) => val)); const offset = this.nodes[0].depth; this.nodes.forEach((d) => { @@ -252,7 +253,10 @@ export const radialLayout = function radialLayout() { * calculate coordinates. Parent depth is assigned as well. * @sideEffect sets this.distance -> "div" or "num_date" */ -export const setDistance = function setDistance(distanceAttribute) { +export const setDistance = function setDistance( + this: PhyloTreeType, + distanceAttribute?: Distance, +): void { timerStart("setDistance"); this.nodes.forEach((d) => {d.update = true;}); if (distanceAttribute) { @@ -292,9 +296,8 @@ export const setDistance = function setDistance(distanceAttribute) { /** * Initializes and sets the range of the scales (this.xScale, this.yScale) * which are used to map the x,y coordinates to the screen - * @param {margins} -- object with "right, left, top, bottom" margins */ -export const setScales = function setScales() { +export const setScales = function setScales(this: PhyloTreeType): void { if (this.layout==="scatter" && !this.scatterVariables.xContinuous) { this.xScale = scalePoint().round(false).align(0.5).padding(0.5); @@ -313,7 +316,7 @@ export const setScales = function setScales() { // Force Square: TODO, harmonize with the map to screen const xExtend = width - this.margins.left - this.margins.right; const yExtend = height - this.margins.bottom - this.margins.top; - const minExtend = min([xExtend, yExtend]); + const minExtend = Math.min(xExtend, yExtend); const xSlack = xExtend - minExtend; const ySlack = yExtend - minExtend; this.xScale.range([0.5 * xSlack + this.margins.left, width - 0.5 * xSlack - this.margins.right]); @@ -337,9 +340,8 @@ export const setScales = function setScales() { /** * this function sets the xScale, yScale domains and maps precalculated x,y * coordinates to their places on the screen -* @return {null} */ -export const mapToScreen = function mapToScreen() { +export const mapToScreen = function mapToScreen(this: PhyloTreeType): void { timerStart("mapToScreen"); const inViewTerminalNodes = this.nodes.filter((d) => !d.n.hasChildren).filter((d) => d.inView); @@ -419,7 +421,7 @@ export const mapToScreen = function mapToScreen() { /* Radial / Unrooted layouts need to be square since branch lengths depend on this */ if (this.layout === "radial" || this.layout === "unrooted") { - const maxSpan = max([spanY, spanX]); + const maxSpan = Math.max(spanY, spanX); const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0; const xSlack = (spanX { d.branch = d.xBase===hiddenXPosition || d.xTip===hiddenXPosition || d.yBase===hiddenYPosition || d.yTip===hiddenYPosition ? - [""] : + ["", ""] : [" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""]; }); } else { - this.nodes.forEach((d) => {d.branch=[];}); + this.nodes.forEach((d) => {d.branch=["", ""];}); } } else if (this.layout==="rect") { this.nodes.forEach((d) => { // d is a @@ -489,8 +491,8 @@ export const mapToScreen = function mapToScreen() { // So we add a tiny amount of jitter (e.g 1/1000px) to the horizontal line (d.branch[0]) // see https://stackoverflow.com/questions/13223636/svg-gradient-for-perfectly-horizontal-path d.branch =[ - [` M ${d.xBase - stem_offset},${d.yBase} L ${d.xTip},${d.yTip+0.01}`], - [` M ${d.xTip},${stemRange[0]} L ${d.xTip},${stemRange[1]}`] + ` M ${d.xBase - stem_offset},${d.yBase} L ${d.xTip},${d.yTip+0.01}`, + ` M ${d.xTip},${stemRange[0]} L ${d.xTip},${stemRange[1]}` ]; if (this.params.confidence) { d.confLine =` M ${this.xScale(d.conf[0])},${d.yBase} L ${this.xScale(d.conf[1])},${d.yTip}`; @@ -506,11 +508,11 @@ export const mapToScreen = function mapToScreen() { " L "+d.xTip.toString()+" "+d.yTip.toString(), "" ]; if (d.n.hasChildren) { - d.branch[1] =[" M "+this.xScale(d.xCBarStart).toString()+" "+this.yScale(d.yCBarStart).toString()+ + d.branch[1] = " M "+this.xScale(d.xCBarStart).toString()+" "+this.yScale(d.yCBarStart).toString()+ " A "+(this.xScale(d.depth)-this.xScale(offset)).toString()+" "+ (this.yScale(d.depth)-this.yScale(offset)).toString()+ " 0 "+(d.smallBigArc?"1 ":"0 ") +" 1 "+ - " "+this.xScale(d.xCBarEnd).toString()+","+this.yScale(d.yCBarEnd).toString()]; + " "+this.xScale(d.xCBarEnd).toString()+","+this.yScale(d.yCBarEnd).toString(); } }); } @@ -519,7 +521,10 @@ export const mapToScreen = function mapToScreen() { const JITTER_MIN_STEP_SIZE = 50; // pixels -function padCategoricalScales(domain, scale) { +function padCategoricalScales( + domain: string[], + scale: d3.ScalePoint, +): d3.ScalePoint { if (scale.step() > JITTER_MIN_STEP_SIZE) return scale.padding(0.5); // balanced padding when we can jitter if (domain.length<=4) return scale.padding(0.4); if (domain.length<=6) return scale.padding(0.3); @@ -530,16 +535,20 @@ function padCategoricalScales(domain, scale) { /** * Add jitter to the already-computed node positions. */ -function jitter(axis, scale, nodes) { +function jitter( + axis: "x" | "y", + scale: d3.ScalePoint, + nodes: PhyloNode[], +): void { const step = scale.step(); if (scale.step() <= JITTER_MIN_STEP_SIZE) return; - const rand = []; // pre-compute a small set of pseudo random numbers for speed + const rand: number[] = []; // pre-compute a small set of pseudo random numbers for speed for (let i=1e2; i--;) { rand.push((Math.random()-0.5)*step*0.5); // occupy 50% } const [base, tip, randLen] = [`${axis}Base`, `${axis}Tip`, rand.length]; let j = 0; - function recurse(phyloNode) { + function recurse(phyloNode: PhyloNode): void { phyloNode[base] = stemParent(phyloNode.n).shell[tip]; phyloNode[tip] += rand[j++]; if (j>=randLen) j=0; @@ -550,7 +559,10 @@ function jitter(axis, scale, nodes) { } -function getTipLabelPadding(params, inViewTerminalNodes) { +function getTipLabelPadding( + params: Params, + inViewTerminalNodes: PhyloNode[], +): number { let padBy = 0; if (inViewTerminalNodes.length < params.tipLabelBreakL1) { @@ -571,6 +583,6 @@ function getTipLabelPadding(params, inViewTerminalNodes) { return padBy; } -function leafWeight(node) { +function leafWeight(node: ReduxNode): number { return node.tipCount + 0.15*(node.fullTipCount-node.tipCount); } diff --git a/src/components/tree/phyloTree/phyloTree.js b/src/components/tree/phyloTree/phyloTree.ts similarity index 92% rename from src/components/tree/phyloTree/phyloTree.js rename to src/components/tree/phyloTree/phyloTree.ts index 9c1129c02..79c2a0f19 100644 --- a/src/components/tree/phyloTree/phyloTree.js +++ b/src/components/tree/phyloTree/phyloTree.ts @@ -1,5 +1,7 @@ +import { ReduxNode } from "../../../reducers/tree/types"; import { createDefaultParams } from "./defaultParams"; import { change, modifySVG, modifySVGInStages } from "./change"; +import { PhyloNode, PhyloTreeType } from "./types"; /* PROTOTYPES */ import * as renderers from "./renderers"; @@ -10,7 +12,12 @@ import * as labels from "./labels"; import * as regression from "./regression"; /* phylogenetic tree drawing function - the actual tree is rendered by the render prototype */ -const PhyloTree = function PhyloTree(reduxNodes, id, idxOfInViewRootNode) { +const PhyloTree = function PhyloTree( + this: PhyloTreeType, + reduxNodes: ReduxNode[], + id: string, + idxOfInViewRootNode: number, +): void { this.grid = false; this.attributes = ['r', 'cx', 'cy', 'id', 'class', 'd']; this.params = createDefaultParams(); @@ -24,14 +31,14 @@ const PhyloTree = function PhyloTree(reduxNodes, id, idxOfInViewRootNode) { -- this.nodes[i].n = reduxNodes[i] -- reduxNodes[i].shell = this.nodes[i] */ this.nodes = reduxNodes.map((d) => { - const phyloNode = { + const phyloNode: PhyloNode = { that: this, - n: d, /* a back link to the redux node */ + n: d, x: 0, y: 0, inView: d.inView !== undefined ? d.inView : true /* each node is visible, unless set earlier! */ }; - d.shell = phyloNode; /* set the link from the redux node to the phylotree node */ + d.shell = phyloNode; return phyloNode; }); this.zoomNode = this.nodes[idxOfInViewRootNode]; diff --git a/src/components/tree/phyloTree/regression.js b/src/components/tree/phyloTree/regression.ts similarity index 85% rename from src/components/tree/phyloTree/regression.js rename to src/components/tree/phyloTree/regression.ts index effcc84d1..aff531295 100644 --- a/src/components/tree/phyloTree/regression.js +++ b/src/components/tree/phyloTree/regression.ts @@ -1,6 +1,7 @@ import { sum } from "d3-array"; import { formatDivergence, guessAreMutationsPerSite} from "./helpers"; import { NODE_VISIBLE } from "../../../util/globals"; +import { PhyloNode, PhyloTreeType, Regression } from "./types"; /** @@ -8,7 +9,7 @@ import { NODE_VISIBLE } from "../../../util/globals"; * the x and y values of terminal nodes which are also visible. * The regression is forced to pass through nodes[0]. */ -function calculateRegressionThroughRoot(nodes) { +function calculateRegressionThroughRoot(nodes: PhyloNode[]): Regression { const terminalNodes = nodes.filter((d) => !d.n.hasChildren && d.visibility === NODE_VISIBLE); const nTips = terminalNodes.length; if (nTips===0) { @@ -31,7 +32,7 @@ function calculateRegressionThroughRoot(nodes) { * Calculate regression through visible terminal nodes which have both x & y values * set. These values must be numeric. */ -function calculateRegressionWithFreeIntercept(nodes) { +function calculateRegressionWithFreeIntercept(nodes: PhyloNode[]): Regression { const terminalNodesWithXY = nodes.filter( (d) => (!d.n.hasChildren) && d.x!==undefined && d.y!==undefined && d.visibility === NODE_VISIBLE ); @@ -50,7 +51,7 @@ function calculateRegressionWithFreeIntercept(nodes) { } /** sets this.regression */ -export function calculateRegression() { +export function calculateRegression(this: PhyloTreeType) { if (this.layout==="clock") { this.regression = calculateRegressionThroughRoot(this.nodes); } else { @@ -58,7 +59,11 @@ export function calculateRegression() { } } -export function makeRegressionText(regression, layout, yScale) { +export function makeRegressionText( + regression: Regression, + layout: string, + yScale: d3.ScaleContinuousNumeric, +): string { if (layout==="clock") { if (guessAreMutationsPerSite(yScale)) { return `rate estimate: ${regression.slope.toExponential(2)} subs per site per year`; diff --git a/src/components/tree/phyloTree/renderers.js b/src/components/tree/phyloTree/renderers.ts similarity index 82% rename from src/components/tree/phyloTree/renderers.js rename to src/components/tree/phyloTree/renderers.ts index b2cd90298..1d8b87bb0 100644 --- a/src/components/tree/phyloTree/renderers.js +++ b/src/components/tree/phyloTree/renderers.ts @@ -3,29 +3,84 @@ import { NODE_VISIBLE } from "../../../util/globals"; import { getDomId, setDisplayOrder } from "./helpers"; import { makeRegressionText } from "./regression"; import { getEmphasizedColor } from "../../../util/colorHelpers"; -/** - * @param {d3 selection} svg -- the svg into which the tree is drawn - * @param {string} layout -- the layout to be used, e.g. "rect" - * @param {string} distance -- the property used as branch length, e.g. div or num_date - * @param {string} focus -- whether to focus on filtered nodes - * @param {object} parameters -- an object that contains options that will be added to this.params - * @param {object} callbacks -- an object with call back function defining mouse behavior - * @param {array} branchThickness -- array of branch thicknesses (same ordering as tree nodes) - * @param {array} visibility -- array of visibility of nodes(same ordering as tree nodes) - * @param {bool} drawConfidence -- should confidence intervals be drawn? - * @param {bool} vaccines -- should vaccine crosses (and dotted lines if applicable) be drawn? - * @param {array} branchStroke -- branch stroke colour for each node (set onto each node) - * @param {array} tipStroke -- tip stroke colour for each node (set onto each node) - * @param {array} tipFill -- tip fill colour for each node (set onto each node) - * @param {array|null} tipRadii -- array of tip radius' - * @param {array} dateRange - * @param {object} scatterVariables -- {x, y} properties to map nodes => scatterplot (only used if layout="scatter") - * @return {null} - */ -export const render = function render(svg, layout, distance, focus, parameters, callbacks, branchThickness, visibility, drawConfidence, vaccines, branchStroke, tipStroke, tipFill, tipRadii, dateRange, scatterVariables) { +import { Callbacks, Distance, Params, PhyloNode, PhyloTreeType } from "./types"; +import { Selection } from "d3"; +import { Layout, ScatterVariables } from "../../../reducers/controls"; +import { ReduxNode, Visibility } from "../../../reducers/tree/types"; + +export const render = function render( + this: PhyloTreeType, +{ + svg, + layout, + distance, + focus, + parameters, + callbacks, + branchThickness, + visibility, + drawConfidence, + vaccines, + branchStroke, + tipStroke, + tipFill, + tipRadii, + dateRange, + scatterVariables +}: { + /** the svg into which the tree is drawn */ + svg: Selection + + /** the layout to be used, e.g. "rect" */ + layout: Layout + + /** the property used as branch length, e.g. div or num_date */ + distance: Distance + + /** whether to focus on filtered nodes */ + focus: boolean + + /** an object that contains options that will be added to this.params */ + parameters: Partial + + /** an object with call back function defining mouse behavior */ + callbacks: Callbacks + + /** array of branch thicknesses (same ordering as tree nodes) */ + branchThickness: number[] + + /** array of visibility of nodes(same ordering as tree nodes) */ + visibility: Visibility[] + + /** should confidence intervals be drawn? */ + drawConfidence: boolean + + /** should vaccine crosses (and dotted lines if applicable) be drawn? */ + vaccines: ReduxNode[] | false + + /** branch stroke colour for each node (set onto each node) */ + branchStroke: string[] + + /** tip stroke colour for each node (set onto each node) */ + tipStroke: string[] + + /** tip fill colour for each node (set onto each node) */ + tipFill: string[] + + /** array of tip radius' */ + tipRadii: number[] | null + + dateRange: [number, number] + + /** {x, y} properties to map nodes => scatterplot (only used if layout="scatter") */ + scatterVariables: ScatterVariables +}) { timerStart("phyloTree render()"); this.svg = svg; - this.params = Object.assign(this.params, parameters); + this.params = { + ...this.params, + ...parameters + }; this.callbacks = callbacks; this.vaccines = vaccines ? vaccines.map((d) => d.shell) : undefined; this.dateRange = dateRange; @@ -67,9 +122,8 @@ export const render = function render(svg, layout, distance, focus, parameters, /** * adds crosses to the vaccines - * @return {null} */ -export const drawVaccines = function drawVaccines() { +export const drawVaccines = function drawVaccines(this: PhyloTreeType): void { if (!this.vaccines || !this.vaccines.length) return; if (!("vaccines" in this.groups)) { @@ -95,9 +149,8 @@ export const drawVaccines = function drawVaccines() { /** * adds all the tip circles to the svg, they have class tip - * @return {null} */ -export const drawTips = function drawTips() { +export const drawTips = function drawTips(this: PhyloTreeType): void { timerStart("drawTips"); const params = this.params; if (!("tips" in this.groups)) { @@ -130,9 +183,8 @@ export const drawTips = function drawTips() { * given a tree node, decide whether the branch should be rendered * This enforces the "hidden" property set on `node.node_attrs.hidden` * in the dataset JSON - * @return {string} */ -export const getBranchVisibility = (d) => { +export const getBranchVisibility = (d: PhyloNode): "visible" | "hidden" => { const hiddenSetting = d.n.node_attrs && d.n.node_attrs.hidden; if (hiddenSetting && ( @@ -148,10 +200,13 @@ export const getBranchVisibility = (d) => { /** Calculate the stroke for a given branch. May return a hex or a `url` referring to * a SVG gradient definition - * @param {obj} d node - * @param {string} b branch type -- either "T" (tee) or "S" (stem) */ -export const strokeForBranch = (d, _b) => { +export const strokeForBranch = ( + d: PhyloNode, + + /** branch type -- either "T" (tee) or "S" (stem) */ + _b?: "T" | "S", +): string => { /* Due to errors rendering gradients on SVG branches on some browsers/OSs which would cause the branches to not appear, we're falling back to the previous solution which doesn't use gradients. The commented code remains & hopefully a solution can be @@ -166,9 +221,8 @@ export const strokeForBranch = (d, _b) => { /** * adds all branches to the svg, these are paths with class branch, which comprise two groups - * @return {null} */ -export const drawBranches = function drawBranches() { +export const drawBranches = function drawBranches(this: PhyloTreeType): void { timerStart("drawBranches"); const params = this.params; @@ -239,9 +293,8 @@ export const drawBranches = function drawBranches() { /** * draws the regression line in the svg and adds a text with the rate estimate - * @return {null} */ -export const drawRegression = function drawRegression() { +export const drawRegression = function drawRegression(this: PhyloTreeType): void { /* check we have computed a sensible regression before attempting to draw */ if (this.regression.slope===undefined) { return; @@ -280,7 +333,7 @@ export const drawRegression = function drawRegression() { .style("font-family", this.params.fontFamily); }; -export const removeRegression = function removeRegression() { +export const removeRegression = function removeRegression(this: PhyloTreeType): void { if ("regression" in this.groups) { this.groups.regression.selectAll("*").remove(); } @@ -289,7 +342,7 @@ export const removeRegression = function removeRegression() { /* * add and remove elements from tree, initial render */ -export const clearSVG = function clearSVG() { +export const clearSVG = function clearSVG(this: PhyloTreeType): void { this.svg.selectAll("*").remove(); }; @@ -338,11 +391,16 @@ export const updateColorBy = function updateColorBy() {}; /** given a node `d` which is being hovered, update it's colour to emphasize * that it's being hovered. This updates the SVG element stroke style in-place * _or_ updates the SVG gradient def in place. - * @param {PhyloNode} d node - * @param {string} c1 colour of the parent (start of the branch) - * @param {string} c2 colour of the node (end of the branch) */ -const handleBranchHoverColor = (d, c1, c2) => { +const handleBranchHoverColor = ( + d: PhyloNode, + + /** colour of the parent (start of the branch) */ + _c1: string, + + /** colour of the node (end of the branch) */ + c2: string, +): void => { if (!d) { return; } /* We want to emphasize the colour of the branch. How we do this depends on how the branch was rendered in the first place! */ @@ -359,12 +417,12 @@ const handleBranchHoverColor = (d, c1, c2) => { } }; -export const branchStrokeForLeave = function branchStrokeForLeave(d) { +export const branchStrokeForLeave = function branchStrokeForLeave(d: PhyloNode) { if (!d) { return; } handleBranchHoverColor(d, d.n.parent.shell.branchStroke, d.branchStroke); }; -export const branchStrokeForHover = function branchStrokeForHover(d) { +export const branchStrokeForHover = function branchStrokeForHover(d: PhyloNode) { if (!d) { return; } handleBranchHoverColor(d, getEmphasizedColor(d.n.parent.shell.branchStroke), getEmphasizedColor(d.branchStroke)); }; @@ -374,7 +432,7 @@ export const branchStrokeForHover = function branchStrokeForHover(d) { * and regression lines. In theory, we can clip to exactly the {xy}Scale range, however * in practice, elements (or portions of elements) render outside this. */ -export const setClipMask = function setClipMask() { +export const setClipMask = function setClipMask(this: PhyloTreeType): void { const [yMin, yMax] = this.yScale.range(); // for the RHS tree (if there is one) ensure that xMin < xMax, else width<0 which some // browsers don't like. See diff --git a/src/components/tree/phyloTree/types.ts b/src/components/tree/phyloTree/types.ts new file mode 100644 index 000000000..3ed139188 --- /dev/null +++ b/src/components/tree/phyloTree/types.ts @@ -0,0 +1,297 @@ +import { Selection } from "d3"; +import { Layout, PerformanceFlags, ScatterVariables } from "../../../reducers/controls"; +import { ReduxNode, Visibility } from "../../../reducers/tree/types"; +import { change, modifySVG, modifySVGInStages } from "./change"; + +import * as confidence from "./confidence"; +import * as grid from "./grid"; +import * as labels from "./labels"; +import * as layouts from "./layouts"; +import * as regression from "./regression"; +import * as renderers from "./renderers"; + +// ---------- Basics ---------- // + +export type Distance = "num_date" | "div" + +export type TreeElement = + ".branch.S" | + ".branch.T" | + ".branch" | + ".branchLabel" | + ".conf" | + ".grid" | + ".regression" | + ".tip" | + ".tipLabel" | + ".vaccineCross" | + ".vaccineDottedLine" + +export interface Regression { + intercept?: number + r2?: number + slope?: number +} + +// ---------- Callbacks ---------- // + +type NodeCallback = (d: PhyloNode) => void + +export interface Callbacks { + onBranchClick: NodeCallback + onBranchHover: NodeCallback + onBranchLeave: NodeCallback + onTipClick: NodeCallback + onTipHover: NodeCallback + onTipLeave: NodeCallback + tipLabel: NodeCallback +} + +// ---------- PhyloNode ---------- // + +/** + * This is a subset of CSSStyleDeclaration. Reasons for not using that directly: + * 1. CSSStyleDeclaration has generic string types for all properties. We allow a number type on many. + * 2. We do not use most CSSStyleDeclaration properties. + * 3. CSSStyleDeclaration uses strokeWidth instead of stroke-width. + * + */ +// TODO: consider extending existing interfaces such as SVGCircleElement for cx/cy/r +interface SVG { + cursor?: unknown + cx?: number + cy?: number + d?: unknown + fill?: unknown + opacity?: unknown + r?: number + stroke?: unknown + "stroke-width"?: number + visibility?: Visibility + + // TODO: This should be `string | number`, conditional on layout + x?: any + + // TODO: This should be `string | number`, conditional on layout + y?: any +} + +export type SVGProperty = keyof SVG + +export interface PhyloNode extends SVG { + angle?: number + branch?: [string, string] + branchStroke?: string + conf?: [number, number] + + /** SVG path */ + confLine?: string + + crossDepth?: number + depth?: number + displayOrder?: number + displayOrderRange?: [number, number] + fill?: string + inView: boolean + n: ReduxNode + pDepth?: number + + // TODO: This should be `string | number`, conditional on layout + px?: any + + // TODO: This should be `string | number`, conditional on layout + py?: any + + rot?: number + smallBigArc?: boolean + tau?: number + that: PhyloTreeType + tipStroke?: string + update?: boolean + + /** SVG path */ + vaccineCross?: string + + w?: number + xBase?: number + xCBarEnd?: number + xCBarStart?: number + xCross?: number + xTip?: number + yBase?: number + yCBarEnd?: number + yCBarStart?: number + yCross?: number + yTip?: number +} + +/** + * Properties can be any property on PhyloNode but as an array for multiple nodes. + * These are the ones that are used in the code. + */ +export interface PropsForPhyloNodes { + branchStroke?: string[] + fill?: string[] + r?: number[] + tipStroke?: (string | undefined)[] + visibility?: Visibility[] +} + +// ---------- PhyloTree ---------- // + +export interface Params { + branchLabelFill: string + branchLabelFont: string + branchLabelFontWeight: number + branchLabelKey: string | false + branchLabelPadX: number + branchLabelPadY: number + branchStroke: string + branchStrokeWidth: number + confidence?: boolean + fillSelected: string + fontFamily: string + grid?: boolean + majorGridStroke: string + majorGridWidth: number + mapToScreenDebounceTime: number + minorGridStroke: string + minorGridWidth: number + minorTicks: number + orientation: [number, number] + radiusSelected: number + regressionStroke: string + regressionWidth: number + showAllBranchLabels?: boolean + showGrid: boolean + showTipLabels?: boolean + tickLabelFill: string + tickLabelSize: number + tipFill: string + tipLabelBreakL1: number + tipLabelBreakL2: number + tipLabelBreakL3: number + tipLabelFill: string + tipLabelFont: string + tipLabelFontSizeL1: number + tipLabelFontSizeL2: number + tipLabelFontSizeL3: number + tipLabelPadX: number + tipLabelPadY: number + tipLabels: boolean + tipRadius: number + tipStroke: string + tipStrokeWidth: number +} + +export interface ChangeParams { + // booleans for what should be changed // + changeColorBy?: boolean + changeVisibility?: boolean + changeTipRadii?: boolean + changeBranchThickness?: boolean + showConfidences?: boolean + removeConfidences?: boolean + zoomIntoClade?: false | PhyloNode + svgHasChangedDimensions?: boolean + animationInProgress?: boolean + changeNodeOrder?: boolean + focus?: boolean + + // change these things to provided value (unless undefined) // + newDistance?: Distance + newLayout?: Layout + updateLayout?: boolean // todo - this seems identical to `newLayout` + newBranchLabellingKey?: string + showAllBranchLabels?: boolean + newTipLabelKey?: string | symbol + + // arrays of data (the same length as nodes) // + branchStroke?: string[] + tipStroke?: (string | undefined)[] + fill?: string[] + visibility?: Visibility[] + tipRadii?: number[] + branchThickness?: number[] + + // other data // + scatterVariables?: ScatterVariables + performanceFlags?: PerformanceFlags +} + +export interface PhyloTreeType { + addGrid: typeof grid.addGrid + attributes: string[] + calculateRegression: typeof regression.calculateRegression + callbacks: Callbacks + change: typeof change + clearSVG: typeof renderers.clearSVG + confidencesInSVG: boolean + dateRange: [number, number] + distance: Distance + drawBranchLabels: typeof labels.drawBranchLabels + drawBranches: typeof renderers.drawBranches + drawConfidence: typeof confidence.drawConfidence + drawRegression: typeof renderers.drawRegression + drawSingleCI: typeof confidence.drawSingleCI + drawTips: typeof renderers.drawTips + drawVaccines: typeof renderers.drawVaccines + grid: boolean + groups: { + branchGradientDefs?: Selection + branchStem?: Selection + branchTee?: Selection + clipPath?: Selection + confidenceIntervals?: Selection + regression?: Selection + tips?: Selection + vaccines?: Selection + } + hideGrid: typeof grid.hideGrid + hideTemporalSlice: typeof grid.hideTemporalSlice + id: string + layout: Layout + mapToScreen: typeof layouts.mapToScreen + margins: { + bottom: number + left: number + right: number + top: number + } + modifySVG: typeof modifySVG + modifySVGInStages: typeof modifySVGInStages + nodes: PhyloNode[] + params: Params + radialLayout: typeof layouts.radialLayout + rectangularLayout: typeof layouts.rectangularLayout + regression?: Regression + removeBranchLabels: typeof labels.removeBranchLabels + removeConfidence: typeof confidence.removeConfidence + removeRegression: typeof renderers.removeRegression + removeTipLabels: typeof labels.removeTipLabels + render: typeof renderers.render + scatterVariables?: ScatterVariables + scatterplotLayout: typeof layouts.scatterplotLayout + setClipMask: typeof renderers.setClipMask + setDistance: typeof layouts.setDistance + setLayout: typeof layouts.setLayout + setScales: typeof layouts.setScales + showTemporalSlice: typeof grid.showTemporalSlice + strainToNode: Record + svg: Selection + timeLastRenderRequested?: number + unrootedLayout: typeof layouts.unrootedLayout + updateBranchLabels: typeof labels.updateBranchLabels + updateColorBy: typeof renderers.updateColorBy + updateTipLabels: typeof labels.updateTipLabels + vaccines?: PhyloNode[] + visibility: Visibility[] + + // TODO: This should be `d3.ScalePoint | d3.ScaleContinuousNumeric`, conditional on layout + xScale: any + + // TODO: This should be `d3.ScalePoint | d3.ScaleContinuousNumeric`, conditional on layout + yScale: any + + zoomNode: PhyloNode +} diff --git a/src/components/tree/reactD3Interface/callbacks.js b/src/components/tree/reactD3Interface/callbacks.ts similarity index 83% rename from src/components/tree/reactD3Interface/callbacks.js rename to src/components/tree/reactD3Interface/callbacks.ts index 0ecc3fc85..e9c11804f 100644 --- a/src/components/tree/reactD3Interface/callbacks.js +++ b/src/components/tree/reactD3Interface/callbacks.ts @@ -1,12 +1,15 @@ -import { updateVisibleTipsAndBranchThicknesses, applyFilter } from "../../../actions/tree"; +import { updateVisibleTipsAndBranchThicknesses, applyFilter, Root } from "../../../actions/tree"; import { NODE_VISIBLE, strainSymbol } from "../../../util/globals"; import { getDomId, getParentBeyondPolytomy, getIdxOfInViewRootNode } from "../phyloTree/helpers"; import { branchStrokeForHover, branchStrokeForLeave } from "../phyloTree/renderers"; +import { PhyloNode } from "../phyloTree/types"; import { SELECT_NODE, DESELECT_NODE } from "../../../actions/types"; +import { SelectedNode } from "../../../reducers/controls"; +import { TreeComponent } from "../tree"; /* Callbacks used by the tips / branches when hovered / selected */ -export const onTipHover = function onTipHover(d) { +export const onTipHover = function onTipHover(this: TreeComponent, d: PhyloNode): void { if (d.visibility !== NODE_VISIBLE) return; const phylotree = d.that.params.orientation[0] === 1 ? this.state.tree : @@ -18,7 +21,7 @@ export const onTipHover = function onTipHover(d) { }); }; -export const onTipClick = function onTipClick(d) { +export const onTipClick = function onTipClick(this: TreeComponent, d: PhyloNode): void { if (d.visibility !== NODE_VISIBLE) return; if (this.props.narrativeMode) return; /* The order of these two dispatches is important: the reducer handling @@ -29,7 +32,7 @@ export const onTipClick = function onTipClick(d) { }; -export const onBranchHover = function onBranchHover(d) { +export const onBranchHover = function onBranchHover(this: TreeComponent, d: PhyloNode): void { if (d.visibility !== NODE_VISIBLE) return; branchStrokeForHover(d); @@ -53,23 +56,23 @@ export const onBranchHover = function onBranchHover(d) { }); }; -export const onBranchClick = function onBranchClick(d) { +export const onBranchClick = function onBranchClick(this: TreeComponent, d: PhyloNode): void { if (d.visibility !== NODE_VISIBLE) return; if (this.props.narrativeMode) return; /* if a branch was clicked while holding the shift key, we instead display a node-clicked modal */ - if (window.event.shiftKey) { + if (window.event instanceof KeyboardEvent && window.event.shiftKey) { // no need to dispatch a filter action this.props.dispatch({type: SELECT_NODE, name: d.n.name, idx: d.n.arrayIdx, isBranch: true, treeId: d.that.id}) return; } - const root = [undefined, undefined]; - let cladeSelected; + const root: Root = [undefined, undefined]; + let cladeSelected: string | undefined; // Branches with multiple labels will be used in the order specified by this.props.tree.availableBranchLabels // (The order of the drop-down on the menu) // Can't use AA mut lists as zoom labels currently - URL is bad, but also, means every node has a label, and many conflict... - let legalBranchLabels; + let legalBranchLabels: string[] | undefined; // Check has some branch labels, and remove 'aa' ones. if (d.n.branch_attrs && d.n.branch_attrs.labels !== undefined) { @@ -98,7 +101,7 @@ export const onBranchClick = function onBranchClick(d) { }; /* onBranchLeave called when mouse-off, i.e. anti-hover */ -export const onBranchLeave = function onBranchLeave(d) { +export const onBranchLeave = function onBranchLeave(this: TreeComponent, d: PhyloNode): void { /* Reset the stroke back to what it was before */ branchStrokeForLeave(d); @@ -112,7 +115,7 @@ export const onBranchLeave = function onBranchLeave(d) { this.setState({hoveredNode: null}); }; -export const onTipLeave = function onTipLeave(d) { +export const onTipLeave = function onTipLeave(this: TreeComponent, d: PhyloNode): void { const phylotree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo; @@ -124,7 +127,7 @@ export const onTipLeave = function onTipLeave(d) { }; /* clearSelectedNode when clicking to remove the node-selected modal */ -export const clearSelectedNode = function clearSelectedNode(selectedNode) { +export const clearSelectedNode = function clearSelectedNode(this: TreeComponent, selectedNode: SelectedNode): void { if (!selectedNode.isBranch) { /* perform the filtering action (if necessary) that will restore the filtering state of the node prior to the selection */ diff --git a/src/components/tree/reactD3Interface/change.js b/src/components/tree/reactD3Interface/change.ts similarity index 89% rename from src/components/tree/reactD3Interface/change.js rename to src/components/tree/reactD3Interface/change.ts index c657bd7a2..24c309b60 100644 --- a/src/components/tree/reactD3Interface/change.js +++ b/src/components/tree/reactD3Interface/change.ts @@ -1,8 +1,18 @@ import { calculateStrokeColors, getBrighterColor } from "../../../util/colorHelpers"; - -export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, newProps) => { - const args = {}; - const newState = {}; +import { ChangeParams, PhyloTreeType } from "../phyloTree/types"; +import { TreeComponentProps, TreeComponentState } from "../types"; + +export const changePhyloTreeViaPropsComparison = ( + mainTree: boolean, + phylotree: PhyloTreeType, + oldProps: TreeComponentProps, + newProps: TreeComponentProps, +): { + newState: Partial | false + change: boolean +} => { + const args: ChangeParams = {}; + const newState: Partial = {}; /* do not use oldProps.tree or newTreeRedux */ const oldTreeRedux = mainTree ? oldProps.tree : oldProps.treeToo; const newTreeRedux = mainTree ? newProps.tree : newProps.treeToo; @@ -111,7 +121,6 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, if (zoomChange) { const rootNode = phylotree.nodes[newTreeRedux.idxOfInViewRootNode]; args.zoomIntoClade = rootNode; - newState.selectedNode = {}; if (newProps.layout === "unrooted") { args.updateLayout = true; } @@ -124,12 +133,15 @@ export const changePhyloTreeViaPropsComparison = (mainTree, phylotree, oldProps, args.svgHasChangedDimensions = true; } - const change = Object.keys(args).length; + const change = Object.keys(args).length > 0; if (change) { args.animationInProgress = newProps.animationPlayPauseButton === "Pause"; args.performanceFlags = newProps.performanceFlags; // console.log('\n\n** ', phylotree.id, 'changePhyloTreeViaPropsComparison **', args); phylotree.change(args); } - return [Object.keys(newState).length ? newState : false, change]; + return { + newState: Object.keys(newState).length ? newState : false, + change, + }; }; diff --git a/src/components/tree/reactD3Interface/initialRender.js b/src/components/tree/reactD3Interface/initialRender.ts similarity index 60% rename from src/components/tree/reactD3Interface/initialRender.js rename to src/components/tree/reactD3Interface/initialRender.ts index 9b44fa0b6..22cec9e4f 100644 --- a/src/components/tree/reactD3Interface/initialRender.js +++ b/src/components/tree/reactD3Interface/initialRender.ts @@ -3,8 +3,16 @@ import 'd3-transition'; import { calculateStrokeColors, getBrighterColor } from "../../../util/colorHelpers"; import * as callbacks from "./callbacks"; import { makeTipLabelFunc } from "../phyloTree/labels"; +import { PhyloTreeType } from "../phyloTree/types"; +import { TreeComponent } from "../tree"; +import { TreeComponentProps } from "../types"; -export const renderTree = (that, main, phylotree, props) => { +export const renderTree = ( + that: TreeComponent, + main: boolean, + phylotree: PhyloTreeType, + props: TreeComponentProps, +): void => { const ref = main ? that.domRefs.mainTree : that.domRefs.secondTree; const treeState = main ? props.tree : props.treeToo; if (!treeState.loaded) { @@ -17,13 +25,13 @@ export const renderTree = (that, main, phylotree, props) => { renderBranchLabels=false; } const tipStrokeColors = calculateStrokeColors(treeState, false, props.colorByConfidence, props.colorBy); - /* simply the call to phylotree.render */ - phylotree.render( - select(ref), - props.layout, - props.distanceMeasure, - props.focus, - { /* parameters (modifies PhyloTree's defaults) */ + + phylotree.render({ + svg: select(ref), + layout: props.layout, + distance: props.distanceMeasure, + focus: props.focus, + parameters: { /* modifies PhyloTree's defaults */ grid: true, confidence: props.temporalConfidence.display, branchLabelKey: renderBranchLabels && props.selectedBranchLabel, @@ -32,7 +40,7 @@ export const renderTree = (that, main, phylotree, props) => { tipLabels: true, showTipLabels: true }, - { /* callbacks */ + callbacks: { onTipHover: callbacks.onTipHover.bind(that), onTipClick: callbacks.onTipClick.bind(that), onBranchHover: callbacks.onBranchHover.bind(that), @@ -41,15 +49,15 @@ export const renderTree = (that, main, phylotree, props) => { onTipLeave: callbacks.onTipLeave.bind(that), tipLabel: makeTipLabelFunc(props.tipLabelKey) }, - treeState.branchThickness, /* guaranteed to be in redux by now */ - treeState.visibility, - props.temporalConfidence.on, /* drawConfidence? */ - treeState.vaccines, - calculateStrokeColors(treeState, true, props.colorByConfidence, props.colorBy), - tipStrokeColors, - tipStrokeColors.map(getBrighterColor), // tip fill colors - treeState.tipRadii, /* might be null */ - [props.dateMinNumeric, props.dateMaxNumeric], - props.scatterVariables - ); + branchThickness: treeState.branchThickness, /* guaranteed to be in redux by now */ + visibility: treeState.visibility, + drawConfidence: props.temporalConfidence.on, + vaccines: treeState.vaccines, + branchStroke: calculateStrokeColors(treeState, true, props.colorByConfidence, props.colorBy), + tipStroke: tipStrokeColors, + tipFill: tipStrokeColors.map(getBrighterColor), + tipRadii: treeState.tipRadii, + dateRange: [props.dateMinNumeric, props.dateMaxNumeric], + scatterVariables: props.scatterVariables, + }); }; diff --git a/src/components/tree/tree.js b/src/components/tree/tree.tsx similarity index 81% rename from src/components/tree/tree.js rename to src/components/tree/tree.tsx index adee6cf43..e4398230c 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.tsx @@ -1,7 +1,8 @@ import React from "react"; import { withTranslation } from "react-i18next"; import { FaSearchMinus } from "react-icons/fa"; -import { updateVisibleTipsAndBranchThicknesses } from "../../actions/tree"; +import { Root, updateVisibleTipsAndBranchThicknesses } from "../../actions/tree"; +import { SelectedNode } from "../../reducers/controls"; import Card from "../framework/card"; import Legend from "./legend/legend"; import PhyloTree from "./phyloTree/phyloTree"; @@ -17,17 +18,26 @@ import { attemptUntangle } from "../../util/globals"; import ErrorBoundary from "../../util/errorBoundary"; import { untangleTreeToo } from "./tangle/untangling"; import { sortByGeneOrder } from "../../util/treeMiscHelpers"; +import { TreeComponentProps, TreeComponentState } from "./types"; export const spaceBetweenTrees = 100; export const lhsTreeId = "LEFT"; const rhsTreeId = "RIGHT"; -class Tree extends React.Component { - constructor(props) { +export class TreeComponent extends React.Component { + + domRefs: { + mainTree: SVGSVGElement | null; + secondTree: SVGSVGElement | null; + }; + tangleRef?: Tangle; + clearSelectedNode: (node: SelectedNode) => void; + + constructor(props: TreeComponentProps) { super(props); this.domRefs = { - mainTree: undefined, - secondTree: undefined + mainTree: null, + secondTree: null }; this.tangleRef = undefined; this.state = { @@ -47,13 +57,13 @@ class Tree extends React.Component { } /* pressing the escape key should dismiss an info modal (if one exists) */ - handlekeydownEvent = (event) => { + handlekeydownEvent = (event: KeyboardEvent) => { if (event.key==="Escape" && this.props.selectedNode) { this.clearSelectedNode(this.props.selectedNode); } } - setUpAndRenderTreeToo(props, newState) { + setUpAndRenderTreeToo(props: TreeComponentProps, newState: Partial) { /* this.setState(newState) will be run sometime after this returns */ /* modifies newState in place */ newState.treeToo = new PhyloTree(props.treeToo.nodes, rhsTreeId, props.treeToo.idxOfInViewRootNode); @@ -63,26 +73,29 @@ class Tree extends React.Component { renderTree(this, false, newState.treeToo, props); } - componentDidMount() { + override componentDidMount() { document.addEventListener('keyup', this.handlekeydownEvent); if (this.props.tree.loaded) { - const newState = {}; + const newState: Partial = {}; newState.tree = new PhyloTree(this.props.tree.nodes, lhsTreeId, this.props.tree.idxOfInViewRootNode); renderTree(this, true, newState.tree, this.props); if (this.props.showTreeToo) { this.setUpAndRenderTreeToo(this.props, newState); /* modifies newState in place */ } newState.geneSortFn = sortByGeneOrder(this.props.genomeMap); - this.setState(newState); /* this will trigger an unnecessary CDU :( */ + this.setState(newState); /* this will trigger an unnecessary CDU :( */ } } - componentDidUpdate(prevProps) { - let newState = {}; + override componentDidUpdate(prevProps: TreeComponentProps) { + let newState: Partial = {}; let rightTreeUpdated = false; /* potentially change the (main / left hand) tree */ - const [potentialNewState, leftTreeUpdated] = changePhyloTreeViaPropsComparison(true, this.state.tree, prevProps, this.props); + const { + newState: potentialNewState, + change: leftTreeUpdated, + } = changePhyloTreeViaPropsComparison(true, this.state.tree, prevProps, this.props); if (potentialNewState) newState = potentialNewState; /* has the 2nd (right hand) tree just been turned on, off or swapped? */ @@ -98,23 +111,29 @@ class Tree extends React.Component { if (this.tangleRef) this.tangleRef.drawLines(); } } else if (this.state.treeToo) { /* the tree hasn't just been swapped, but it does exist and may need updating */ - let _unusedNewState; - [_unusedNewState, rightTreeUpdated] = changePhyloTreeViaPropsComparison(false, this.state.treeToo, prevProps, this.props); - /* note, we don't incorporate _unusedNewState into the state? why not? */ + ({ + change: rightTreeUpdated, + } = changePhyloTreeViaPropsComparison(false, this.state.treeToo, prevProps, this.props)); + /* note, we don't incorporate newState into the state? why not? */ } /* we may need to (imperatively) tell the tangle to redraw */ if (this.tangleRef && (leftTreeUpdated || rightTreeUpdated)) { this.tangleRef.drawLines(); } - if (Object.keys(newState).length) this.setState(newState); + if (Object.keys(newState).length) this.setState(newState); } - componentWillUnmount() { + override componentWillUnmount() { document.removeEventListener('keyup', this.handlekeydownEvent); } - getStyles = () => { + getStyles = (): { + treeButtonsDiv: React.CSSProperties + resetTreeButton: React.CSSProperties + zoomToSelectedButton: React.CSSProperties + zoomOutButton: React.CSSProperties + } => { const filteredTree = !!this.props.tree.idxOfFilteredRoot && this.props.tree.idxOfInViewRootNode !== this.props.tree.idxOfFilteredRoot; const filteredTreeToo = !!this.props.treeToo.idxOfFilteredRoot && @@ -156,7 +175,15 @@ class Tree extends React.Component { }; }; - renderTreeDiv({width, height, mainTree}) { + renderTreeDiv({ + width, + height, + mainTree, + }: { + width: number + height: number + mainTree: boolean + }) { return ( { - let newRoot, newRootToo; + const root: Root = [undefined, undefined]; // Zoom out of main tree if index of root node is not 0 if (this.props.tree.idxOfInViewRootNode !== 0) { const rootNode = this.props.tree.nodes[this.props.tree.idxOfInViewRootNode]; - newRoot = getParentBeyondPolytomy(rootNode, this.props.distanceMeasure, this.props.tree.observedMutations).arrayIdx; + root[0] = getParentBeyondPolytomy(rootNode, this.props.distanceMeasure, this.props.tree.observedMutations).arrayIdx; } // Also zoom out of second tree if index of root node is not 0 if (this.props.treeToo.idxOfInViewRootNode !== 0) { const rootNodeToo = this.props.treeToo.nodes[this.props.treeToo.idxOfInViewRootNode]; - newRootToo = getParentBeyondPolytomy(rootNodeToo, this.props.distanceMeasure, this.props.treeToo.observedMutations).arrayIdx; + root[1] = getParentBeyondPolytomy(rootNodeToo, this.props.distanceMeasure, this.props.treeToo.observedMutations).arrayIdx; } - const root = [newRoot, newRootToo]; this.props.dispatch(updateVisibleTipsAndBranchThicknesses({root})); } - render() { + override render() { const { t } = this.props; const styles = this.getStyles(); const widthPerTree = this.props.showTreeToo ? (this.props.width - spaceBetweenTrees) / 2 : this.props.width; @@ -272,5 +298,4 @@ class Tree extends React.Component { } } -const WithTranslation = withTranslation()(Tree); -export default WithTranslation; +export default withTranslation()(TreeComponent); diff --git a/src/components/tree/types.ts b/src/components/tree/types.ts new file mode 100644 index 000000000..c3773949b --- /dev/null +++ b/src/components/tree/types.ts @@ -0,0 +1,57 @@ +import { WithTranslation } from "react-i18next" +import { ColorScale, Layout, PerformanceFlags, ScatterVariables, SelectedNode, TemporalConfidence } from "../../reducers/controls"; +import { TreeState, TreeTooState } from "../../reducers/tree/types"; +import { AppDispatch } from "../../store"; +import { Distance, PhyloNode, PhyloTreeType } from "./phyloTree/types"; + +export interface TreeComponentOwnProps { + dispatch: AppDispatch + height: number + width: number +} + +export interface TreeComponentProps extends WithTranslation, TreeComponentStateProps, TreeComponentOwnProps {} + +// This is duplicated from RootState, but good to be explicit about what's +// expected here. +export interface TreeComponentStateProps { + animationPlayPauseButton: "Play" | "Pause" + canRenderBranchLabels: boolean + colorBy: string + colorByConfidence: boolean + colorings: unknown + colorScale: ColorScale + dateMaxNumeric: number + dateMinNumeric: number + distanceMeasure: Distance + explodeAttr: string + filters: Record> + focus: boolean + genomeMap: unknown + layout: Layout + narrativeMode: boolean + panelsToDisplay: string[] + performanceFlags: PerformanceFlags + quickdraw: boolean + scatterVariables: ScatterVariables + selectedBranchLabel: string + selectedNode: SelectedNode | null + showAllBranchLabels: boolean + showOnlyPanels: boolean + showTangle: boolean + showTreeToo: boolean + temporalConfidence: TemporalConfidence + tipLabelKey: string | symbol + tree: TreeState + treeToo: TreeTooState +} + +export interface TreeComponentState { + hoveredNode: { + node: PhyloNode + isBranch: boolean + } | null + tree: PhyloTreeType | null + treeToo: PhyloTreeType | null + geneSortFn?: (a: number, b: number) => number | (() => 0) +} diff --git a/src/globalStyles.js b/src/globalStyles.ts similarity index 84% rename from src/globalStyles.js rename to src/globalStyles.ts index 2f4490241..c57533fd5 100644 --- a/src/globalStyles.js +++ b/src/globalStyles.ts @@ -15,7 +15,7 @@ export const goColor = "#89B77F"; // green export const pauseColor = "#E39B39"; // orange // http://stackoverflow.com/questions/1895476/how-to-style-a-select-dropdown-with-css-only-without-javascript -export const sidebarField = { +export const sidebarField: React.CSSProperties = { backgroundColor: "#FFF", fontFamily: dataFont, width: controlsWidth - 13, @@ -31,7 +31,7 @@ export const sidebarField = { marginBottom: "3px" }; -export const materialButton = { +export const materialButton: React.CSSProperties = { border: "0px", backgroundColor: "inherit", marginLeft: 0, @@ -50,7 +50,7 @@ export const materialButton = { outline: 0 }; -export const materialButtonSelected = { +export const materialButtonSelected: React.CSSProperties = { border: "0px", backgroundColor: "inherit", marginLeft: 0, @@ -69,7 +69,7 @@ export const materialButtonSelected = { outline: 0 }; -export const materialButtonOutline = { +export const materialButtonOutline: React.CSSProperties = { border: "1px solid #CCC", backgroundColor: "inherit", borderRadius: 3, @@ -86,7 +86,7 @@ export const materialButtonOutline = { verticalAlign: "top" }; -export const tabSingle = { +export const tabSingle: React.CSSProperties = { borderTop: "1px solid #BBB", borderLeft: "1px solid #CCC", borderRight: "1px solid #CCC", @@ -104,7 +104,7 @@ export const tabSingle = { textTransform: "uppercase" }; -export const tabGroup = { +export const tabGroup: React.CSSProperties = { borderTop: "1px solid #BBB", borderLeft: "1px solid #CCC", borderRight: "1px solid #CCC", @@ -117,7 +117,7 @@ export const tabGroup = { backgroundColor: "#fff" }; -export const tabGroupMember = { +export const tabGroupMember: React.CSSProperties = { border: "none", backgroundColor: "inherit", padding: 0, @@ -130,7 +130,7 @@ export const tabGroupMember = { fontSize: 12 }; -export const tabGroupMemberSelected = { +export const tabGroupMemberSelected: React.CSSProperties = { border: "none", backgroundColor: "inherit", padding: 0, @@ -144,7 +144,10 @@ export const tabGroupMemberSelected = { }; -export const titleStyles = { +export const titleStyles: { + big: React.CSSProperties + small: React.CSSProperties +} = { big: { fontFamily: titleFont, fontSize: 76, @@ -166,7 +169,21 @@ export const titleStyles = { } }; -export const infoPanelStyles = { +export const infoPanelStyles: { + branchInfoHeading: React.CSSProperties + buttonLink: React.CSSProperties + tooltip: React.CSSProperties + modalContainer: React.CSSProperties + panel: React.CSSProperties + modalHeading: React.CSSProperties + modalSubheading: React.CSSProperties + tooltipHeading: React.CSSProperties + comment: React.CSSProperties + topRightMessage: React.CSSProperties + list: React.CSSProperties + item: React.CSSProperties + break: React.CSSProperties +} = { branchInfoHeading: { fontSize: 15, fontWeight: 400, diff --git a/src/metadata.ts b/src/metadata.ts new file mode 100644 index 000000000..73b60ea2a --- /dev/null +++ b/src/metadata.ts @@ -0,0 +1,36 @@ +import { ScaleType } from "./reducers/controls" + +export type Metadata = { + rootSequence?: unknown + rootSequenceSecondTree?: unknown + identicalGenomeMapAcrossBothTrees?: boolean + colorings: Colorings +} + +export type Colorings = { + [key: string]: ColoringInfo +} + +export type ColoringInfo = { + title: string + type: ScaleType + + /** scale set via JSON */ + scale: [string, string][] + + legend?: Legend +} + +export type Legend = { + /** + * Used to compute the legend swatch colour. The type of this depends on the scaleType. + * Continuous scales demand numeric values, however few restrictions are placed on other scales. + */ + value: unknown + + /** Displayed in the legend. Falls back to `value` if missing. */ + display?: string | number + + /** Custom legendBounds. Only considered for continuous scales. */ + bounds?: [number, number] +}[] diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index 5290c2440..c04271d0c 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -12,35 +12,143 @@ import * as types from "../actions/types"; import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; import { hasMultipleGridPanels } from "../actions/panelDisplay"; +import { Distance } from "../components/tree/phyloTree/types"; -type Layout = "rect" | "radial" | "unrooted" | "clock" | "scatter" + +export interface ColorScale { + colorBy: string + continuous: boolean + domain?: unknown[] + genotype: Genotype | null + legendBounds?: LegendBounds + legendLabels?: LegendLabels + legendValues: LegendValues + scale: (value: any) => string + scaleType: ScaleType | null + version: number + visibleLegendValues: LegendValues +} + +export interface Genotype { + gene: string + positions: number[] + aa: boolean +} + +export type Layout = "rect" | "radial" | "unrooted" | "clock" | "scatter" + +export type LegendBounds = { + [key: string | number]: [number, number] +} + +/** A map of legendValues to a value for display in the legend. */ +export type LegendLabels = Map + +/** An array of values to display in the legend. */ +// TODO: I think this should be number[] | string[] but that requires adding type guards +export type LegendValues = any[] + +export type PerformanceFlags = Map + +export interface SelectedNode { + existingFilterState: "active" | "inactive" | null + idx: number + isBranch: boolean + name: string + treeId: string +} + +export type ScaleType = "ordinal" | "categorical" | "continuous" | "temporal" | "boolean" + +export interface ScatterVariables { + showBranches?: boolean + showRegression?: boolean + x?: string + xContinuous?: boolean + xDomain?: number[] + xTemporal?: boolean + y?: string + yContinuous?: boolean + yDomain?: number[] + yTemporal?: boolean +} + +export interface TemporalConfidence { + exists: boolean + display: boolean + on: boolean +} interface Defaults { - distanceMeasure: string + distanceMeasure: Distance layout: Layout focus: boolean geoResolution: string - filters: Record + filters: Record filtersInFooter: string[] colorBy: string selectedBranchLabel: string - tipLabelKey: typeof strainSymbol + tipLabelKey: string | symbol showTransmissionLines: boolean sidebarOpen?: boolean } export interface BasicControlsState { defaults: Defaults + + absoluteDateMax: string + absoluteDateMaxNumeric: number + absoluteDateMin: string + absoluteDateMinNumeric: number + analysisSlider: boolean + animationPlayPauseButton: "Play" | "Pause" + available?: boolean + branchLengthsToDisplay: string + canRenderBranchLabels: boolean + canTogglePanelLayout: boolean + colorBy: string + colorByConfidence: boolean + coloringsPresentOnTree: Set + + /** subset of coloringsPresentOnTree */ + coloringsPresentOnTreeWithConfidence: Set + + colorScale?: ColorScale + dateMax: string + dateMaxNumeric: number + dateMin: string + dateMinNumeric: number + distanceMeasure: Distance + explodeAttr?: string + filters: Record> + filtersInFooter: string[] + focus: boolean + geoResolution: string layout: Layout + mapAnimationCumulative: boolean + mapAnimationDurationInMilliseconds: number + mapAnimationShouldLoop: boolean + mapAnimationStartDate: unknown + modal: 'download' | 'linkOut' | null + normalizeFrequencies: boolean + panelLayout: string panelsAvailable: string[] panelsToDisplay: string[] + performanceFlags: PerformanceFlags + quickdraw: boolean + scatterVariables: ScatterVariables + selectedBranchLabel: string + selectedNode: SelectedNode | null + showAllBranchLabels: boolean + showOnlyPanels: boolean + showTangle: boolean + showTransmissionLines: boolean showTreeToo: boolean - canTogglePanelLayout: boolean - focus: boolean - - // This allows arbitrary prop names while TypeScript adoption is incomplete. - // TODO: add all other props explicitly and remove this. - [propName: string]: any; + sidebarOpen: boolean + temporalConfidence: TemporalConfidence + tipLabelKey: string | symbol + zoomMax?: number + zoomMin?: number } export interface MeasurementsControlState { @@ -58,7 +166,7 @@ export interface ControlsState extends BasicControlsState, MeasurementsControlSt /* defaultState is a fn so that we can re-create it at any time, e.g. if we want to revert things (e.g. on dataset change) */ -export const getDefaultControlsState = () => { +export const getDefaultControlsState = (): ControlsState => { const defaults: Defaults = { distanceMeasure: defaultDistanceMeasure, layout: defaultLayout, @@ -102,6 +210,8 @@ export const getDefaultControlsState = () => { colorBy: defaults.colorBy, colorByConfidence: false, colorScale: undefined, + coloringsPresentOnTree: new Set(), + coloringsPresentOnTreeWithConfidence: new Set(), explodeAttr: undefined, selectedBranchLabel: "none", showAllBranchLabels: false, @@ -128,8 +238,6 @@ export const getDefaultControlsState = () => { zoomMax: undefined, branchLengthsToDisplay: "divAndDate", sidebarOpen: initialSidebarState.sidebarOpen, - treeLegendOpen: undefined, - mapLegendOpen: undefined, showOnlyPanels: false, showTransmissionLines: true, normalizeFrequencies: true, @@ -297,7 +405,7 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con const existingFilterInfo = (state.filters?.[strainSymbol]||[]).find((info) => info.value===action.name); const existingFilterState = existingFilterInfo === undefined ? null : existingFilterInfo.active ? 'active' : 'inactive'; - const selectedNode = {name: action.name, idx: action.idx, existingFilterState, isBranch: action.isBranch, treeId: action.treeId}; + const selectedNode: SelectedNode = {name: action.name, idx: action.idx, existingFilterState, isBranch: action.isBranch, treeId: action.treeId}; return {...state, selectedNode}; } case types.DESELECT_NODE: { diff --git a/src/reducers/index.ts b/src/reducers/index.ts index c6fcf7c7b..f0cef104c 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,27 +1,28 @@ import { combineReducers } from "redux"; import metadata from "./metadata"; import tree from "./tree"; +import { TreeState, TreeTooState } from "./tree/types"; import frequencies from "./frequencies"; import entropy from "./entropy"; import controls, { ControlsState } from "./controls"; import browserDimensions from "./browserDimensions"; import notifications from "./notifications"; import narrative, { NarrativeState } from "./narrative"; -import treeToo from "./treeToo"; +import treeToo from "./tree/treeToo"; import general from "./general"; import jsonCache from "./jsonCache"; import measurements from "./measurements"; interface RootState { metadata: ReturnType - tree: ReturnType + tree: TreeState frequencies: ReturnType controls: ControlsState entropy: ReturnType browserDimensions: ReturnType notifications: ReturnType narrative: NarrativeState - treeToo: ReturnType + treeToo: TreeTooState general: ReturnType jsonCache: ReturnType measurements: ReturnType diff --git a/src/reducers/tree.js b/src/reducers/tree/index.ts similarity index 73% rename from src/reducers/tree.js rename to src/reducers/tree/index.ts index 6581364f3..b7d08f2e0 100644 --- a/src/reducers/tree.js +++ b/src/reducers/tree/index.ts @@ -1,11 +1,10 @@ -import { countTraitsAcrossTree } from "../util/treeCountingHelpers"; -import { addNodeAttrs } from "../util/treeMiscHelpers"; -import * as types from "../actions/types"; +import { AnyAction } from "@reduxjs/toolkit"; +import { countTraitsAcrossTree } from "../../util/treeCountingHelpers"; +import { addNodeAttrs } from "../../util/treeMiscHelpers"; +import * as types from "../../actions/types"; +import { TreeState, TreeTooState } from "./types"; -/* A version increase (i.e. props.version !== nextProps.version) necessarily implies -that the tree is loaded as they are set on the same action */ - -export const getDefaultTreeState = () => { +export const getDefaultTreeState = (): TreeState | TreeTooState => { return { loaded: false, nodes: null, @@ -29,19 +28,23 @@ export const getDefaultTreeState = () => { }; -const Tree = (state = getDefaultTreeState(), action) => { +const Tree = ( + state: TreeState = getDefaultTreeState(), + action: AnyAction, +): TreeState => { switch (action.type) { case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: /* fallthrough */ case types.CLEAN_START: return action.tree; case types.DATA_INVALID: - return Object.assign({}, state, { - loaded: false - }); + return { + ...state, + loaded: false, + }; case types.CHANGE_EXPLODE_ATTR: /* fallthrough */ case types.CHANGE_DATES_VISIBILITY_THICKNESS: /* fallthrough */ case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: { - const newStates = { + const newStates: Partial = { visibility: action.visibility, visibilityVersion: action.visibilityVersion, branchThickness: action.branchThickness, @@ -51,18 +54,23 @@ const Tree = (state = getDefaultTreeState(), action) => { cladeName: action.cladeName, selectedClade: action.cladeName, }; - return Object.assign({}, state, newStates); + return { + ...state, + ...newStates, + }; } case types.UPDATE_TIP_RADII: - return Object.assign({}, state, { + return { + ...state, tipRadii: action.data, - tipRadiiVersion: action.version - }); + tipRadiiVersion: action.version, + }; case types.NEW_COLORS: - return Object.assign({}, state, { + return { + ...state, nodeColors: action.nodeColors, - nodeColorsVersion: action.version - }); + nodeColorsVersion: action.version, + }; case types.TREE_TOO_DATA: return action.tree; case types.ADD_EXTRA_METADATA: { diff --git a/src/reducers/treeToo.js b/src/reducers/tree/treeToo.ts similarity index 72% rename from src/reducers/treeToo.js rename to src/reducers/tree/treeToo.ts index e5c67a46b..fd5b75de7 100644 --- a/src/reducers/treeToo.js +++ b/src/reducers/tree/treeToo.ts @@ -1,18 +1,21 @@ -import * as types from "../actions/types"; -import { addNodeAttrs } from "../util/treeMiscHelpers"; -import { getDefaultTreeState } from "./tree"; -/* A version increase (i.e. props.version !== nextProps.version) necessarily implies -that the tree is loaded as they are set on the same action */ - -const treeToo = (state = getDefaultTreeState(), action) => { +import { AnyAction } from "@reduxjs/toolkit"; +import { getDefaultTreeState } from "."; +import { addNodeAttrs } from "../../util/treeMiscHelpers"; +import * as types from "../../actions/types"; +import { TreeTooState } from "./types"; +const treeToo = ( + state: TreeTooState = getDefaultTreeState(), + action: AnyAction, +): TreeTooState => { /* There are only a few actions we should always listen for, as they can change the presence / absence of the second tree */ switch (action.type) { case types.DATA_INVALID: - return Object.assign({}, state, { - loaded: false - }); + return { + ...state, + loaded: false, + }; case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: /* fallthrough */ case types.CLEAN_START: if (action.treeToo) { @@ -37,7 +40,8 @@ const treeToo = (state = getDefaultTreeState(), action) => { case types.CHANGE_DATES_VISIBILITY_THICKNESS: /* fallthrough */ case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: if (action.tangleTipLookup) { - return Object.assign({}, state, { + return { + ...state, tangleTipLookup: action.tangleTipLookup, visibility: action.visibilityToo, visibilityVersion: action.visibilityVersionToo, @@ -45,20 +49,22 @@ const treeToo = (state = getDefaultTreeState(), action) => { branchThicknessVersion: action.branchThicknessVersionToo, idxOfInViewRootNode: action.idxOfInViewRootNodeToo, idxOfFilteredRoot: action.idxOfFilteredRootToo, - }); + }; } return state; case types.UPDATE_TIP_RADII: - return Object.assign({}, state, { + return { + ...state, tipRadii: action.dataToo, - tipRadiiVersion: action.version - }); + tipRadiiVersion: action.version, + }; case types.NEW_COLORS: if (action.nodeColorsToo) { - return Object.assign({}, state, { + return { + ...state, nodeColors: action.nodeColorsToo, - nodeColorsVersion: action.version - }); + nodeColorsVersion: action.version, + }; } return state; case types.ADD_EXTRA_METADATA: diff --git a/src/reducers/tree/types.ts b/src/reducers/tree/types.ts new file mode 100644 index 000000000..722b06fb2 --- /dev/null +++ b/src/reducers/tree/types.ts @@ -0,0 +1,86 @@ +import { NODE_NOT_VISIBLE, NODE_VISIBLE, NODE_VISIBLE_TO_MAP_ONLY } from "../../util/globals"; +import { PhyloNode } from "../../components/tree/phyloTree/types"; + +/** + * Maps mutation strings (in format gene:fromPosTo, e.g. 'nuc:A123T') + * to their occurrence count in the tree + */ +export type Mutations = Record + +export interface ReduxNode { + /** the index of the node in the nodes array. set so that we can access visibility / nodeColors if needed */ + arrayIdx?: number + + branch_attrs?: { + mutations?: { + [gene: string]: string[] + } + labels?: Record + } + children?: ReduxNode[] + currentGt?: string + + /** see the number of subtending tips (alive or dead) */ + fullTipCount?: number + + hasChildren?: boolean + inView?: boolean + name?: string + node_attrs?: { + div?: number + hidden?: "always" | "timetree" | "divtree" + num_date?: { + value: number + } + } + parent?: ReduxNode + parentInfo?: { + original: ReduxNode + } + shell?: PhyloNode + + /** the number of visible tips */ + tipCount?: number + + unexplodedChildren?: ReduxNode[] +} + +/** + * Keys: the traits + * Values: a Map of trait values to count + */ +export type TraitCounts = Record> + +export interface TreeState { + availableBranchLabels: string[] + branchThickness: number[] | null + branchThicknessVersion: number + cladeName?: string + idxOfFilteredRoot?: number + idxOfInViewRootNode: number + loaded: boolean + name?: string + nodeAttrKeys?: Set + nodeColors: string[] | null + nodeColorsVersion: number + nodes: ReduxNode[] | null + observedMutations: Mutations + selectedClade?: string + tipRadii: number[] | null + tipRadiiVersion: number + totalStateCounts: TraitCounts + vaccines: ReduxNode[] | false + /** + * A version increase (i.e. props.version !== nextProps.version) necessarily implies + * that the tree is loaded as they are set on the same action + */ + version: number + visibility: Visibility[] | null + visibilityVersion: number +} + +export interface TreeTooState extends TreeState { + tangleTipLookup?: unknown[][] +} + +export type Visibility = typeof NODE_NOT_VISIBLE | typeof NODE_VISIBLE_TO_MAP_ONLY | typeof NODE_VISIBLE diff --git a/src/util/colorScale.js b/src/util/colorScale.ts similarity index 75% rename from src/util/colorScale.js rename to src/util/colorScale.ts index f92e56b0e..f85f80471 100644 --- a/src/util/colorScale.js +++ b/src/util/colorScale.ts @@ -1,5 +1,4 @@ -import scaleOrdinal from "d3-scale/src/ordinal"; -import scaleLinear from "d3-scale/src/linear"; +import { scaleLinear, scaleOrdinal } from "d3-scale"; import { min, max, range as d3Range } from "d3-array"; import { rgb } from "d3-color"; import { interpolateHcl } from "d3-interpolate"; @@ -10,19 +9,24 @@ import { isColorByGenotype, decodeColorByGenotype } from "./getGenotype"; import { setGenotype, orderOfGenotypeAppearance } from "./setGenotype"; import { getTraitFromNode } from "./treeMiscHelpers"; import { sortedDomain } from "./sortedDomain"; +import { ColoringInfo, Legend, Metadata } from "../metadata"; +import { ColorScale, ControlsState, Genotype, LegendBounds, LegendLabels, LegendValues, ScaleType } from "../reducers/controls"; +import { ReduxNode, TreeState, TreeTooState, Visibility } from "../reducers/tree/types"; export const unknownColor = "#ADB1B3"; /** * calculate the color scale. - * @param {string} colorBy - provided trait to use as color - * @param {object} controls - * @param {object} tree - * @param {object} treeToo - * @param {object} metadata - * @return {{scale: function, continuous: string, colorBy: string, version: int, legendValues: Array, legendBounds: Array|undefined, genotype: null|object, scaleType: null|string, visibleLegendValues: Array}} */ -export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { +export const calcColorScale = ( + /** provided trait to use as color */ + colorBy: string, + + controls: ControlsState, + tree: TreeState, + treeToo: TreeTooState, + metadata: Metadata, +): ColorScale => { try { if (colorBy === "none") { throw new Error("colorBy is 'none'. Falling back to a default, uninformative color scale."); @@ -33,9 +37,13 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { const colorings = metadata.colorings; const treeTooNodes = treeToo ? treeToo.nodes : undefined; let continuous = false; - let colorScale, legendValues, legendBounds, legendLabels, domain; + let colorScale: (val: any) => string; + let legendValues: LegendValues; + let legendBounds: LegendBounds; + let legendLabels: LegendLabels; + let domain: unknown[]; - let genotype; + let genotype: Genotype; if (isColorByGenotype(colorBy)) { genotype = decodeColorByGenotype(colorBy); setGenotype(tree.nodes, genotype.gene, genotype.positions, metadata.rootSequence); /* modifies nodes recursively */ @@ -43,7 +51,7 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { setGenotype(treeToo.nodes, genotype.gene, genotype.positions, metadata.rootSequenceSecondTree); } } - const scaleType = genotype ? "categorical" : colorings[colorBy].type; + const scaleType: ScaleType = genotype ? "categorical" : colorings[colorBy].type; if (genotype) { ({legendValues, colorScale} = createScaleForGenotype(tree.nodes, treeToo?.nodes, genotype.aa)); domain = [...legendValues]; @@ -109,7 +117,8 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { }; } catch (err) { /* Catch all errors to avoid app crashes */ - console.error("Error creating color scales. Details:\n", err.message); + const errorMessage = err instanceof Error ? err.message : String(err); + console.error("Error creating color scales. Details:\n", errorMessage); return { scale: () => unknownColor, continuous: false, @@ -125,14 +134,23 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { } }; -export function createNonContinuousScaleFromProvidedScaleMap(colorBy, providedScale, t1nodes, t2nodes) { +export function createNonContinuousScaleFromProvidedScaleMap( + colorBy: string, + providedScale: [string, string][], + t1nodes: ReduxNode[], + t2nodes: ReduxNode[] | undefined, +): { + continuous: boolean + legendValues: LegendValues + colorScale: ColorScale["scale"] +} { // console.log(`calcColorScale: colorBy ${colorBy} provided us with a scale (list of [trait, hex])`); if (!Array.isArray(providedScale)) { throw new Error(`${colorBy} has defined a scale which wasn't an array`); } /* The providedScale may have duplicate names (not ideal, but it happens). In this case we should filter out duplicates (taking the first of the duplicates is fine) & print a console warning */ - const colorMap = new Map(); + const colorMap = new Map(); for (const [name, colorHex] of providedScale) { if (colorMap.has(name)) { console.warn(`User provided color scale contained a duplicate entry for ${colorBy}→${name} which is ignored.`); @@ -143,7 +161,7 @@ export function createNonContinuousScaleFromProvidedScaleMap(colorBy, providedSc let domain = Array.from(colorMap).map((x) => x[0]); /* create shades of grey for values in the tree which weren't defined in the provided scale */ - const extraVals = getExtraVals(t1nodes, t2nodes, colorBy, domain); + const extraVals: string[] = getExtraVals(t1nodes, t2nodes, colorBy, domain); if (extraVals.length) { // we must add these to the domain + provide a color value domain = domain.concat(extraVals); const extraColors = createListOfColors(extraVals.length, ["#BDC3C6", "#868992"]); @@ -154,11 +172,18 @@ export function createNonContinuousScaleFromProvidedScaleMap(colorBy, providedSc return { continuous: false, /* colorMaps can't (yet) be continuous */ legendValues: domain, - colorScale: (val) => (colorMap.get(val) || unknownColor) + colorScale: (val: string) => (colorMap.get(val) || unknownColor) }; } -function createScaleForGenotype(t1nodes, t2nodes, aaGenotype) { +function createScaleForGenotype( + t1nodes: ReduxNode[], + t2nodes: ReduxNode[], + aaGenotype: boolean, +): { + colorScale: ColorScale["scale"] + legendValues: LegendValues +} { const legendValues = orderOfGenotypeAppearance(t1nodes, t2nodes, aaGenotype); const trueValues = aaGenotype ? legendValues.filter((x) => x !== "X" && x !== "-" && x !== "") : @@ -167,21 +192,30 @@ function createScaleForGenotype(t1nodes, t2nodes, aaGenotype) { const range = [unknownColor, ...genotypeColors.slice(0, trueValues.length)]; // Bases are returned by orderOfGenotypeAppearance in order, unknowns at end if (legendValues.indexOf("-") !== -1) { - range.push(rgb(217, 217, 217)); + range.push(rgb(217, 217, 217).formatHex()); } if (legendValues.indexOf("N") !== -1 && !aaGenotype) { - range.push(rgb(153, 153, 153)); + range.push(rgb(153, 153, 153).formatHex()); } if (legendValues.indexOf("X") !== -1) { - range.push(rgb(102, 102, 102)); + range.push(rgb(102, 102, 102).formatHex()); } return { - colorScale: scaleOrdinal().domain(domain).range(range), + colorScale: scaleOrdinal().domain(domain).range(range), legendValues }; } -function createOrdinalScale(colorBy, t1nodes, t2nodes) { +function createOrdinalScale( + colorBy: string, + t1nodes: ReduxNode[], + t2nodes: ReduxNode[], +): { + continuous: boolean + colorScale: ColorScale["scale"] + legendValues: LegendValues + legendBounds: LegendBounds +} { /* currently, ordinal scales are only implemented for those with integer values. TODO: we should be able to have non-numerical ordinal scales (e.g. `["small", "medium", "large"]`) however we currently cannot specify this ordering @@ -190,7 +224,8 @@ function createOrdinalScale(colorBy, t1nodes, t2nodes) { let legendValues = getDiscreteValuesFromTree(t1nodes, t2nodes, colorBy); const allInteger = legendValues.every((x) => Number.isInteger(x)); let continuous = false; - let colorScale, legendBounds; + let colorScale: ColorScale["scale"]; + let legendBounds: Record; if (allInteger) { const minMax = getMinMaxFromTree(t1nodes, t2nodes, colorBy); @@ -204,7 +239,7 @@ function createOrdinalScale(colorBy, t1nodes, t2nodes) { duplication, as this is identical to that of the continuous scale below */ console.warn("Using a continous scale as there are too many values in the ordinal scale"); continuous = true; - const scale = scaleLinear().domain(genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0]))).range(colors[9]); + const scale = scaleLinear().domain(genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0]))).range(colors[9]); colorScale = (val) => isValueValid(val) ? scale(val): unknownColor; const spread = minMax[1] - minMax[0]; const dp = spread > 5 ? 2 : 3; @@ -221,7 +256,17 @@ function createOrdinalScale(colorBy, t1nodes, t2nodes) { return {continuous, colorScale, legendValues, legendBounds}; } -function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes) { +function createContinuousScale( + colorBy: string, + providedScale, + t1nodes: ReduxNode[], + t2nodes: ReduxNode[], +): { + continuous: boolean + colorScale: ColorScale["scale"] + legendBounds: LegendBounds + legendValues: LegendValues +} { const minMax = getMinMaxFromTree(t1nodes, t2nodes, colorBy); @@ -229,7 +274,8 @@ function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes) { const anchorPoints = _validateAnchorPoints(providedScale, (val) => typeof val==="number"); /* make the continuous scale */ - let domain, range; + let domain: number[]; + let range: string[]; if (anchorPoints) { domain = anchorPoints.map((pt) => pt[0]); range = anchorPoints.map((pt) => pt[1]); @@ -237,7 +283,7 @@ function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes) { range = colors[9]; domain = genericDomain.map((d) => minMax[0] + d * (minMax[1] - minMax[0])); } - const scale = scaleLinear().domain(domain).range(range); + const scale = scaleLinear().domain(domain).range(range); const spread = minMax[1] - minMax[0]; const dp = spread > 5 ? 2 : 3; @@ -252,16 +298,27 @@ function createContinuousScale(colorBy, providedScale, t1nodes, t2nodes) { return { continuous: true, - colorScale: (val) => isValueValid(val) ? scale(val) : unknownColor, + colorScale: (val: number) => isValueValid(val) ? scale(val) : unknownColor, legendBounds: createLegendBounds(legendValues), legendValues }; } -function createTemporalScale(colorBy, providedScale, t1nodes, t2nodes) { - - let domain, range; +function createTemporalScale( + colorBy: string, + providedScale, + t1nodes: ReduxNode[], + t2nodes: ReduxNode[], +): { + continuous: boolean + colorScale: ColorScale["scale"] + legendBounds: LegendBounds + legendValues: LegendValues +} { + + let domain: number[]; + let range: string[]; const anchorPoints = _validateAnchorPoints(providedScale, (val) => numDate(val)!==undefined); if (anchorPoints) { domain = anchorPoints.map((pt) => numDate(pt[0])); @@ -282,14 +339,14 @@ function createTemporalScale(colorBy, providedScale, t1nodes, t2nodes) { vals = vals.sort(); domain = [rootDate]; const n = 10; - const spaceBetween = parseInt(vals.length / (n - 1), 10); + const spaceBetween = Math.trunc(vals.length / (n - 1)); for (let i = 0; i < (n-1); i++) domain.push(vals[spaceBetween*i]); domain.push(vals[vals.length-1]); domain = [...new Set(domain)]; /* filter to unique values only */ range = colors[domain.length]; /* use the right number of colours */ } - const scale = scaleLinear().domain(domain).range(range); + const scale = scaleLinear().domain(domain).range(range); const legendValues = anchorPoints ? domain.slice() : domain.slice(1); @@ -310,26 +367,40 @@ function createTemporalScale(colorBy, providedScale, t1nodes, t2nodes) { } -function getMinMaxFromTree(nodes, nodesToo, attr) { +function getMinMaxFromTree( + nodes: ReduxNode[], + nodesToo: ReduxNode[], + attr: string, +): [number, number] { const arr = nodesToo ? nodes.concat(nodesToo) : nodes.slice(); - const vals = arr.map((n) => getTraitFromNode(n, attr)) + const vals: number[] = arr.map((n) => getTraitFromNode(n, attr)) .filter((n) => n !== undefined) .filter((item, i, ar) => ar.indexOf(item) === i) .map((v) => +v); // coerce throw new Error(to numeric return [min(vals), max(vals)]; } -/* this creates a (ramped) list of colours - this is necessary as ordinal scales can't interpolate colours. - range: [a,b], the colours to go between */ -function createListOfColors(n, range) { - const scale = scaleLinear().domain([0, n]) +/** + * this creates a (ramped) list of colours + * this is necessary as ordinal scales can't interpolate colours. + */ +function createListOfColors( + n: number, + + /** the colours to go between */ + range: [string, string], +) { + const scale = scaleLinear().domain([0, n]) .interpolate(interpolateHcl) .range(range); return d3Range(0, n).map(scale); } -function getDiscreteValuesFromTree(nodes, nodesToo, attr) { +function getDiscreteValuesFromTree( + nodes: ReduxNode[], + nodesToo: ReduxNode[] | undefined, + attr: string, +): LegendValues { const stateCount = countTraitsAcrossTree(nodes, [attr], false, false)[attr]; if (nodesToo) { const stateCountSecondTree = countTraitsAcrossTree(nodesToo, [attr], false, false)[attr]; @@ -348,7 +419,12 @@ function getDiscreteValuesFromTree(nodes, nodesToo, attr) { * This code is in this file to help future refactors, as the colorScale code has grown a lot * and could be greatly improved. james, dec 2021 */ -export function getLegendOrder(attr, coloringInfo, nodesA, nodesB) { +export function getLegendOrder( + attr: string, + coloringInfo: ColoringInfo, + nodesA: ReduxNode[], + nodesB: ReduxNode[] | undefined, +): LegendValues { if (isColorByGenotype(attr)) { console.warn("legend ordering for genotypes not yet implemented"); return []; @@ -357,7 +433,7 @@ export function getLegendOrder(attr, coloringInfo, nodesA, nodesB) { console.warn("legend ordering for continuous scales not yet implemented"); return []; } - if (coloringInfo.scale) { /* scale set via JSON */ + if (coloringInfo.scale) { return createNonContinuousScaleFromProvidedScaleMap(attr, coloringInfo.scale, nodesA, nodesB).legendValues; } return getDiscreteValuesFromTree(nodesA, nodesB, attr); @@ -366,56 +442,70 @@ export function getLegendOrder(attr, coloringInfo, nodesA, nodesB) { /** * Dynamically create legend values based on visibility for ordinal and categorical scale types. */ -export function createVisibleLegendValues({colorBy, scaleType, genotype, legendValues, treeNodes, treeTooNodes, visibility, visibilityToo}) { +export function createVisibleLegendValues({ + colorBy, + scaleType, + genotype, + legendValues, + treeNodes, + treeTooNodes, + visibility, + visibilityToo, +}: { + colorBy: string + scaleType: ScaleType + genotype: Genotype + legendValues: LegendValues + treeNodes: ReduxNode[] + treeTooNodes?: ReduxNode[] | null + visibility: Visibility[] + visibilityToo?: Visibility[] +}): LegendValues { if (visibility) { // filter according to scaleType, e.g. continuous is different to categorical which is different to boolean // filtering will involve looping over reduxState.tree.nodes and comparing with reduxState.tree.visibility if (scaleType === "ordinal" || scaleType === "categorical") { - let legendValuesObserved = treeNodes + let legendValuesObserved: LegendValues = treeNodes .filter((n, i) => (!n.hasChildren && visibility[i]===NODE_VISIBLE)) .map((n) => genotype ? n.currentGt : getTraitFromNode(n, colorBy)); // if the 2nd tree is enabled, compute visible legend values and merge the values. if (treeTooNodes && visibilityToo) { - const legendValuesObservedToo = treeTooNodes + const legendValuesObservedToo: LegendValues = treeTooNodes .filter((n, i) => (!n.hasChildren && visibilityToo[i]===NODE_VISIBLE)) .map((n) => genotype ? n.currentGt : getTraitFromNode(n, colorBy)); legendValuesObserved = [...legendValuesObserved, ...legendValuesObservedToo]; } - legendValuesObserved = new Set(legendValuesObserved); - const visibleLegendValues = legendValues.filter((v) => legendValuesObserved.has(v)); + const legendValuesObservedSet = new Set(legendValuesObserved); + const visibleLegendValues = legendValues.filter((v) => legendValuesObservedSet.has(v)); return visibleLegendValues; } } return legendValues.slice(); } -function createDiscreteScale(domain, type) { +function createDiscreteScale(domain: string[], type: ScaleType) { // note: colors[n] has n colors - let colorList; + let colorList: string[]; if (type==="ordinal" || type==="categorical") { /* TODO: use different colours! */ colorList = domain.length < colors.length ? colors[domain.length].slice() : colors[colors.length - 1].slice(); } - const scale = scaleOrdinal().domain(domain).range(colorList); + const scale = scaleOrdinal().domain(domain).range(colorList); return (val) => ((val === undefined || domain.indexOf(val) === -1)) ? unknownColor : scale(val); } -function booleanColorScale(val) { +function booleanColorScale(val: unknown): string { if (!isValueValid(val)) return unknownColor; if (["true", "1", "yes"].includes(String(val).toLowerCase())) return "#4C90C0"; return "#CBB742"; } -/** - * @param {Array} legendValues - * @returns {Record} - */ -function createLegendBounds(legendValues) { - const valBetween = (x0, x1) => x0 + 0.5*(x1-x0); +function createLegendBounds(legendValues: number[]): LegendBounds { + const valBetween = (x0: number, x1: number) => x0 + 0.5*(x1-x0); const len = legendValues.length; - const legendBounds = {}; + const legendBounds: LegendBounds = {}; legendBounds[legendValues[0]] = [-Infinity, valBetween(legendValues[0], legendValues[1])]; for (let i = 1; i < len - 1; i++) { legendBounds[legendValues[i]] = [valBetween(legendValues[i-1], legendValues[i]), valBetween(legendValues[i], legendValues[i+1])]; @@ -424,7 +514,10 @@ function createLegendBounds(legendValues) { return legendBounds; } -function _validateAnchorPoints(providedScale, validator) { +function _validateAnchorPoints( + providedScale: unknown[], + validator: (val: unknown) => boolean, +): unknown[] | false { if (!Array.isArray(providedScale)) return false; const ap = providedScale.filter((item) => Array.isArray(item) && item.length===2 && @@ -437,20 +530,19 @@ function _validateAnchorPoints(providedScale, validator) { /** * Parse the user-defined `legend` for a given coloring to produce legendValues, legendLabels and legendBounds. - * - * @param {Array|undefined} providedLegend JSON-defined `legend` array of objects. Object keys: - * `value` used to compute the legend swatch colour. The type of this depends on the scaleType. - * Continuous scales demand numeric values, however few restrictions are placed on other scales. - * `display` -> string|numeric. Optional. Displayed in the legend. Falls back to `value` if missing. - * `bounds` -> array of 2 numerics. Optional. Custom legendBounds. Only considered for continuous scales. - * @param {Array} currentLegendValues Dynamically generated legendValues (via traversal of tree(s)). - * @param {string} scaleType - * @returns {false|object}. Returned object has keys: - * `legendValues` -> array of numeric legend values for display - * `legendBounds` -> {object|undefined} See `createLegendBounds()` for format - * `legendLabels` -> {Map|undefined} A map of legendValues to a value for display in the legend. */ -function parseUserProvidedLegendData(providedLegend, currentLegendValues, scaleType) { +function parseUserProvidedLegendData( + providedLegend: Legend | undefined, + + /** Dynamically generated legendValues (via traversal of tree(s)). */ + currentLegendValues: LegendValues, + + scaleType: ScaleType, +): { + legendValues: LegendValues + legendLabels: LegendLabels + legendBounds: LegendBounds +} | false { if (!Array.isArray(providedLegend)) return false; if (scaleType==='temporal') { console.error("Auspice currently doesn't allow a JSON-provided 'legend' for temporal colorings, "+ @@ -466,19 +558,19 @@ function parseUserProvidedLegendData(providedLegend, currentLegendValues, scaleT return false; } - const legendValues = data.map((d) => d.value); + const legendValues: LegendValues = data.map((d) => d.value); - const legendLabels = new Map( + const legendLabels: LegendLabels = new Map( data.map((d) => { return (typeof d.display === "string" || typeof d.display === "number") ? [d.value, d.display] : [d.value, d.value]; }) ); - let legendBounds = {}; + let legendBounds: LegendBounds = {}; if (scaleType==="continuous") { const boundArrays = data.map((d) => d.bounds) .filter((b) => Array.isArray(b) && b.length === 2 && typeof b[0] === "number" && typeof b[1] === "number") - .map(([a, b]) => a > b ? [b, a] : [a, b]) // ensure each bound is correctly ordered + .map(([a, b]): [number, number] => a > b ? [b, a] : [a, b]) // ensure each bound is correctly ordered .filter(([a, b], idx, arr) => { // ensure no overlap with previous bounds. for (let i=0; i { +export const determineLegendMatch = ( + /** e.g. "USA" or 2021 */ + selectedLegendItem: string | number, + + /** node (tip) in question */ + node: ReduxNode, + + /** used to get the value of the attribute being used for colouring */ + colorScale: ColorScale +): boolean => { let nodeAttr = getTipColorAttribute(node, colorScale); if (colorScale.scaleType === 'temporal') { nodeAttr = numDate(nodeAttr); @@ -29,34 +36,48 @@ export const determineLegendMatch = (selectedLegendItem: (string|number), node:a /** * Does the `node`s trait for the given `geoResolution` match the `geoValueToMatch`? - * @param {object} node - node (tip) in question - * @param {string} geoResolution - Geographic resolution (e.g. "division", "country", "region") - * @param {string} geoValueToMatch - Value to match (e.g. "New Zealand", "New York") - * @returns bool */ -const determineLocationMatch = (node:any, geoResolution:string, geoValueToMatch:string) => { +const determineLocationMatch = ( + /** node (tip) in question */ + node: ReduxNode, + + /** Geographic resolution (e.g. "division", "country", "region") */ + geoResolution: string, + + /** Value to match (e.g. "New Zealand", "New York") */ + geoValueToMatch: string +): boolean => { return geoValueToMatch === getTraitFromNode(node, geoResolution); }; /** * produces the array of tip radii - if nothing's selected this is the hardcoded tipRadius * if there's a selectedLegendItem, then values will be small (like normal) or big (for those tips selected) -* @param selectedLegendItem - value of the selected tip attribute (numeric or string) OPTIONAL -* @param tipSelectedIdx - idx of a single tip to show with increased tipRadius OPTIONAL -* @param colorScale - node (tip) in question -* @param tree * @returns null (if data not ready) or array of tip radii */ -export const calcTipRadii = ( - {tipSelectedIdx = false, selectedLegendItem = false, geoFilter = [], searchNodes = false, colorScale, tree}: - {tipSelectedIdx:(number|false), selectedLegendItem:(number|string|false), geoFilter:([string, string]|[]), searchNodes:any, colorScale:any, tree:any} -) => { +export const calcTipRadii = ({ + tipSelectedIdx = false, + selectedLegendItem = false, + geoFilter = [], + colorScale, + tree +}: { + /** idx of a single tip to show with increased tipRadius */ + tipSelectedIdx?: number | false + + /** value of the selected tip attribute (numeric or string) */ + selectedLegendItem?: number | string | false + + geoFilter?: [string, string] | [] + + colorScale: ColorScale + + tree: TreeState +}): number[] | null => { if (selectedLegendItem !== false && tree && tree.nodes) { - return tree.nodes.map((d:any) => determineLegendMatch(selectedLegendItem, d, colorScale) ? tipRadiusOnLegendMatch : tipRadius); + return tree.nodes.map((d) => determineLegendMatch(selectedLegendItem, d, colorScale) ? tipRadiusOnLegendMatch : tipRadius); } else if (geoFilter.length===2 && tree && tree.nodes) { - return tree.nodes.map((d:any) => determineLocationMatch(d, geoFilter[0], geoFilter[1]) ? tipRadiusOnLegendMatch : tipRadius); - } else if (searchNodes) { - return tree.nodes.map((d:any) => d.name.toLowerCase().includes(searchNodes) ? tipRadiusOnLegendMatch : tipRadius); + return tree.nodes.map((d) => determineLocationMatch(d, geoFilter[0], geoFilter[1]) ? tipRadiusOnLegendMatch : tipRadius); } else if (tipSelectedIdx) { const radii = tree.nodes.map(() => tipRadius); radii[tipSelectedIdx] = tipRadiusOnLegendMatch + 3; diff --git a/src/util/treeCountingHelpers.js b/src/util/treeCountingHelpers.ts similarity index 68% rename from src/util/treeCountingHelpers.js rename to src/util/treeCountingHelpers.ts index 1c440cb03..a26217224 100644 --- a/src/util/treeCountingHelpers.js +++ b/src/util/treeCountingHelpers.ts @@ -1,16 +1,25 @@ +import { Colorings } from "../metadata"; +import { ReduxNode, TraitCounts, Visibility } from "../reducers/tree/types"; import { NODE_VISIBLE } from "./globals"; import { getTraitFromNode } from "./treeMiscHelpers"; /** * traverse the tree to get state counts for supplied traits. -* @param {Array} nodes - list of nodes -* @param {Array} traits - list of traits to count across the tree -* @param {Array | false} visibility - if Array provided then only consider visible nodes. If false, consider all nodes. -* @param {bool} terminalOnly - only consider terminal / leaf nodes? -* @return {obj} keys: the traits. Values: an object mapping trait values -> INT */ -export const countTraitsAcrossTree = (nodes, traits, visibility, terminalOnly) => { - const counts = {}; +export const countTraitsAcrossTree = ( + /** list of nodes */ + nodes: ReduxNode[], + + /** list of traits to count across the tree */ + traits: string[], + + /** if Array provided then only consider visible nodes. If false, consider all nodes. */ + visibility: Visibility[] | false, + + /** only consider terminal / leaf nodes? */ + terminalOnly: boolean, +): TraitCounts => { + const counts: TraitCounts = {}; traits.forEach((trait) => {counts[trait] = new Map();}); nodes.forEach((node) => { @@ -39,16 +48,16 @@ export const countTraitsAcrossTree = (nodes, traits, visibility, terminalOnly) = * Includes a hardcoded list of trait names we will ignore, as well as any trait * which we know is continuous (via a colouring definition) because the * filtering is not designed for these kinds of data (yet). - * @param {Array} nodes - * @param {Object} colorings - * @returns {Array} list of trait names */ -export const gatherTraitNames = (nodes, colorings) => { +export const gatherTraitNames = ( + nodes: ReduxNode[], + colorings: Colorings, +): string[] => { const ignore = new Set([ 'num_date', ...Object.entries(colorings).filter(([_, info]) => info.type==='continuous').map(([name, _]) => name), ]) - const names = new Set(); + const names = new Set(); for (const node of nodes) { if (node.hasChildren) continue; for (const traitName in node.node_attrs || {}) { @@ -63,12 +72,15 @@ export const gatherTraitNames = (nodes, colorings) => { } /** -* for each node, calculate the number of subtending tips which are visible -* side effects: n.tipCount for each node -* @param {Node} node - deserialized JSON root to begin traversal -* @param {Array} visibility -*/ -export const calcTipCounts = (node, visibility) => { + * for each node, calculate the number of subtending tips which are visible + * side effects: n.tipCount for each node + */ +export const calcTipCounts = ( + /** deserialized JSON root to begin traversal */ + node: ReduxNode, + + visibility: Visibility[], +): void => { node.tipCount = 0; if (typeof node.children !== "undefined") { for (let i = 0; i < node.children.length; i++) { @@ -82,9 +94,8 @@ export const calcTipCounts = (node, visibility) => { /** * calculate the total number of tips in the tree - * @param {Array} nodes flat list of all nodes */ -export const calcTotalTipsInTree = (nodes) => { +export const calcTotalTipsInTree = (nodes: ReduxNode[]): number => { let count = 0; nodes.forEach((n) => { if (!n.hasChildren) count++; @@ -95,9 +106,11 @@ export const calcTotalTipsInTree = (nodes) => { /** * for each node, calculate the number of subtending tips (alive or dead) * side effects: n.fullTipCount for each node -* @param {Node} node - deserialized JSON root to begin traversal */ -export const calcFullTipCounts = (node) => { +export const calcFullTipCounts = ( + /** deserialized JSON root to begin traversal */ + node: ReduxNode, +): void => { node.fullTipCount = 0; if (typeof node.children !== "undefined") { for (let i = 0; i < node.children.length; i++) { diff --git a/src/util/treeJsonProcessing.js b/src/util/treeJsonProcessing.ts similarity index 75% rename from src/util/treeJsonProcessing.js rename to src/util/treeJsonProcessing.ts index 39e7fc91a..a4acaff78 100644 --- a/src/util/treeJsonProcessing.js +++ b/src/util/treeJsonProcessing.ts @@ -1,24 +1,25 @@ import { getDefaultTreeState } from "../reducers/tree"; +import { Mutations, ReduxNode, TreeState } from "../reducers/tree/types"; import { getVaccineFromNode, getTraitFromNode, getDivFromNode } from "./treeMiscHelpers"; import { calcFullTipCounts } from "./treeCountingHelpers"; const pseudoRandomName = () => (Math.random()*1e32).toString(36).slice(0, 6); /** - * Adds certain properties to the nodes array - for each node in nodes it adds - * node.fullTipCount - see calcFullTipCounts() description - * node.hasChildren {bool} - * node.arrayIdx {integer} - the index of the node in the nodes array - * @param {array} nodes redux tree nodes - * @return {Object} ret - * @return {Set} ret.nodeAttrKeys collection of all `node_attr` keys whose values are Objects - * @return {Array} ret.nodes input array (kinda unnecessary) - * - * side-effects: node.hasChildren (bool) and node.arrayIdx (INT) for each node in nodes + * Adds the following properties to each node: + * - fullTipCount + * - hasChildren + * - arrayIdx */ -const processNodes = (nodes) => { - const nodeNamesSeen = new Set(); - const nodeAttrKeys = new Set(); +const processNodes = (nodes: ReduxNode[]): { + /** collection of all `node_attr` keys whose values are Objects */ + nodeAttrKeys: Set + + /** input array (kinda unnecessary) */ + nodes: ReduxNode[] +} => { + const nodeNamesSeen = new Set(); + const nodeAttrKeys = new Set(); calcFullTipCounts(nodes[0]); /* recursive. Uses d.children */ nodes.forEach((d, idx) => { d.arrayIdx = idx; /* set an index so that we can access visibility / nodeColors if needed */ @@ -50,10 +51,9 @@ const processNodes = (nodes) => { /** * Scan the tree for `node.branch_attrs.labels` dictionaries and collect all available * (These are the options for the "Branch Labels" sidebar dropdown) - * @param {Array} nodes tree nodes (flat) */ -const processBranchLabelsInPlace = (nodes) => { - const availableBranchLabels = new Set(); +const processBranchLabelsInPlace = (nodes: ReduxNode[]): string[] => { + const availableBranchLabels = new Set(); nodes.forEach((n) => { if (n.branch_attrs && n.branch_attrs.labels) { Object.keys(n.branch_attrs.labels) @@ -68,8 +68,11 @@ const processBranchLabelsInPlace = (nodes) => { }; -const makeSubtreeRootNode = (nodesArray, subtreeIndicies) => { - const node = { +const makeSubtreeRootNode = ( + nodesArray: ReduxNode[], + subtreeIndicies: number[], +): ReduxNode => { + const node: ReduxNode = { name: "__ROOT", node_attrs: {hidden: "always"}, children: subtreeIndicies.map((idx) => nodesArray[idx]) @@ -87,11 +90,16 @@ const makeSubtreeRootNode = (nodesArray, subtreeIndicies) => { * Pre-order tree traversal visits each node using stack. * Checks if leaf node based on node.children * pushes all children into stack and continues traversal. -* @param root - deserialized JSON root to begin traversal -* @returns array - final array of nodes in order with no dups */ -const flattenTree = (root) => { - const stack = [], array = []; +const flattenTree = ( + /** deserialized JSON root to begin traversal */ + root: ReduxNode, +): ReduxNode[] => { + const stack: ReduxNode[] = []; + + /** final array of nodes in order with no dups */ + const array: ReduxNode[] = []; + stack.push(root); while (stack.length !== 0) { const node = stack.pop(); @@ -111,11 +119,13 @@ const flattenTree = (root) => { * Pre-order tree traversal visits each node using stack. * Checks if leaf node based on node.children * pushes all children into stack and continues traversal. -* @param root - deserialized JSON root to begin traversal */ -const appendParentsToTree = (root) => { +const appendParentsToTree = ( + /** deserialized JSON root to begin traversal */ + root: ReduxNode, +): void => { root.parent = root; - const stack = []; + const stack: ReduxNode[] = []; stack.push(root); while (stack.length !== 0) { @@ -133,9 +143,8 @@ const appendParentsToTree = (root) => { * Currently this is limited in scope, but is intended to parse * information on a branch_attr indicating information about minor/ * major parents (e.g. recombination, subtree position in another tree). - * @param {Array} nodes */ -const addParentInfo = (nodes) => { +const addParentInfo = (nodes: ReduxNode[]): void => { nodes.forEach((n) => { n.parentInfo = { original: n.parent @@ -145,16 +154,12 @@ const addParentInfo = (nodes) => { /** * Collects all mutations on the tree - * @param {Node[]} nodesArray - * @return {Object} - * keys are mutations in gene:fromPosTo format (e.g. nuc:A123T) - * values are integers representing occurrences on tree * @todo The original remit of this function was for homoplasy detection. * If storing all the mutations becomes an issue, we may be able use an array * of mutations observed more than once. */ -const collectObservedMutations = (nodesArray) => { - const mutations = {}; +const collectObservedMutations = (nodesArray: ReduxNode[]): Mutations => { + const mutations: Mutations = {}; nodesArray.forEach((n) => { if (!n.branch_attrs || !n.branch_attrs.mutations) return; Object.entries(n.branch_attrs.mutations).forEach(([gene, muts]) => { @@ -166,9 +171,9 @@ const collectObservedMutations = (nodesArray) => { return mutations; }; -export const treeJsonToState = (treeJSON) => { +export const treeJsonToState = (treeJSON): TreeState => { const trees = Array.isArray(treeJSON) ? treeJSON : [treeJSON]; - const nodesArray = []; + const nodesArray: ReduxNode[] = []; const subtreeIndicies = []; for (const treeRootNode of trees) { appendParentsToTree(treeRootNode); @@ -184,7 +189,13 @@ export const treeJsonToState = (treeJSON) => { }); const availableBranchLabels = processBranchLabelsInPlace(nodesArray); const observedMutations = collectObservedMutations(nodesArray); - return Object.assign({}, getDefaultTreeState(), { - nodes, nodeAttrKeys, vaccines, observedMutations, availableBranchLabels, loaded: true - }); + return { + ...getDefaultTreeState(), + nodes, + nodeAttrKeys, + vaccines, + observedMutations, + availableBranchLabels, + loaded: true, + }; }; diff --git a/tsconfig.json b/tsconfig.json index 4b8fa23c5..c6275dc6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ Visit https://aka.ms/tsconfig.json for a detailed list of options. "compilerOptions": { /* Language and Environment */ "jsx": "react", /* Specify what JSX code is generated. */ + "target": "es2015", /* Modules */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ @@ -31,6 +32,7 @@ Visit https://aka.ms/tsconfig.json for a detailed list of options. /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ + "strictNullChecks": false, /* Allow unhandled false/null/undefined values to make incremental TypeScript adoption easier. */ "noImplicitAny": false, /* Allow implicit any to make incremental TypeScript adoption easier. */ "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */