diff --git a/client/src/components/world_tree.css b/client/src/components/world_tree.css index 5cf54fb..28768dd 100644 --- a/client/src/components/world_tree.css +++ b/client/src/components/world_tree.css @@ -45,3 +45,9 @@ padding: 3px; } } */ + +.world-label { + /* border: 2px solid purple; */ + padding: .2em; + border-radius: .5em; +} diff --git a/client/src/components/world_tree.tsx b/client/src/components/world_tree.tsx index c064cc6..5ea7036 100644 --- a/client/src/components/world_tree.tsx +++ b/client/src/components/world_tree.tsx @@ -21,37 +21,73 @@ import './world_tree.css' // Settings for the world tree cytoscape.use( klay ) -const N = 18 // max number of levels per world -const R = 64 // radius of a world -const r = 12 // radius of a level -const s = 10 // global scale -const padding = R + 2*r // padding of the graphic (on a different scale) -const ds = .75 // scale the resulting svg image + +const r = 16 // radius of a level +const s = 12 // global scale +const lineWidth = 10 // +const ds = .75 // scale the resulting svg image + +const NMIN = 5 // min. worldsize +const NLABEL = 9 // max. world size to display label below the world +const NMAX = 16 // max. world size. Level icons start spiraling out if the world has more levels. // colours const grey = '#999' const lightgrey = '#bbb' -const green = 'green' +const green = 'green' // 118a11? const lightgreen = '#139e13' const blue = '#1976d2' +const darkgrey = '#868686' +const darkgreen = '#0e770e' +const darkblue = '#1667b8' + /** svg object for a level in the game tree */ -export function LevelIcon({ world, level, position, completed, unlocked }: +export function LevelIcon({ world, level, position, completed, unlocked, worldSize }: { world: string, level: number, position: cytoscape.Position, completed: boolean, unlocked: boolean, + worldSize: number }) { + + const N = Math.max(worldSize, NMIN) + + // divide circle into `N+2` equal pieces + const beta = 2 * Math.PI / Math.min(N+2, (NMAX+1)) + + // We want distance between two level icons to be `2.2*r`, therefore: + // Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2) + let R = 1.1 * r / Math.sin(beta/2) + const gameId = React.useContext(GameIdContext) const difficulty = useSelector(selectDifficulty(gameId)) - const x = s * position.x + Math.sin(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N)) - const y = s * position.y - Math.cos(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N)) const levelDisabled = (difficulty >= 2 && !(unlocked || completed)) + + /** In the spiral, the angle `β` should decrease to avoid big gaps between levels. + * This is a simplified function, which has little mathematical foundation, but + * works fine in tests up to `N=30`. + */ + function betaSpiral(level) { + return 2 * Math.PI / ((NMAX+1) + Math.max(0, (level-2)) / (NMAX+1)) + } + + const x = N < (NMAX+1) ? + // normal case + s * position.x + Math.sin(level * beta) * R : + // spiraling case + s * position.x + Math.sin(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NMAX+1)) + const y = N < (NMAX+1) ? + // normal case + s * position.y - Math.cos(level * beta) * R : + // spiraling case + s * position.y - Math.cos(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NMAX+1)) + return ( - +
@@ -65,12 +101,22 @@ export function LevelIcon({ world, level, position, completed, unlocked }: } /** svg object of one world in the game tree */ -export function WorldIcon({world, title, position, completedLevels, difficulty}: +export function WorldIcon({world, title, position, completedLevels, difficulty, worldSize}: { world: string, title: string, position: cytoscape.Position, completedLevels: any, - difficulty: number }) { + difficulty: number, + worldSize: number + }) { + + // See level icons. Match radius computed there minus `1.2*r` + const N = Math.max(worldSize, NMIN) + const betaHalf = Math.PI / Math.min(N+2, (NMAX + 1)) + let R = 1.1 * r / Math.sin(betaHalf) - 1.2 * r + + // Offset for the labels for small worlds + let labelOffset = R + 2.5 * r // index `0` indicates that all prerequisites are completed let unlocked = completedLevels[0] @@ -84,7 +130,6 @@ export function WorldIcon({world, title, position, completedLevels, difficulty}: nextLevel = 0 } let playable = difficulty <= 1 || completed || unlocked - const gameId = React.useContext(GameIdContext) return - -
-

- {title ? title : world} -

-
-
+ {worldSize > NLABEL ? + // Label for large worlds + +
+

+ {title ? title : world} +

+
+
+ : + // Label for small worlds + +
+

+ {title ? title : world} +

+
+
} } @@ -107,7 +165,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty}: export function WorldPath({source, target, unlocked} : {source: any, target: any, unlocked: boolean}) { return + stroke={unlocked ? green : grey} strokeWidth={lineWidth} /> } /** Download a file containing `data` */ @@ -286,7 +344,9 @@ export function computeWorldLayout(worlds) { headless: true, styleEnabled: false }) - const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run() + + cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run() + let nodes = {} cy.nodes().forEach((node, id) => { nodes[node.id()] = { @@ -355,21 +415,32 @@ export function WorldTreePanel({worlds, worldSize}: position={position} completedLevels={completed[worldId]} difficulty={difficulty} - key={`${gameId}-${worldId}`} /> + key={`${gameId}-${worldId}`} + worldSize={worldSize[worldId]} + /> ) + for (let i = 1; i <= worldSize[worldId]; i++) { svgElements.push( + completed={completed[worldId][i]} + unlocked={completed[worldId][i-1]} + key={`${gameId}-${worldId}-${i}`} + worldSize={worldSize[worldId]} + /> ) } } } + // See `LevelIcon` for calculation of the radius. Use the max. radius for calculating the padding + // TODO: Is there a way to determine padding according to the drawn objects? + let R = 1.1 * r / Math.sin(Math.PI / (NMAX+1)) + const padding = R + 2.1*r + let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null return