<script lang="ts"> import { onDestroy } from 'svelte' import { tweened } from 'svelte/motion' import { cubicOut } from 'svelte/easing' import { scaleLinear } from "d3-scale" const DEG_PER_RAD:number = 180/Math.PI export let fullWidth:number = 500 $: centeredWidth = Math.min(500, fullWidth) $: fullHeight = centeredWidth const PADDING = 30 $: width = centeredWidth - 2*PADDING $: height = fullHeight - 2*PADDING $: halfWidth = width/2 $: halfHeight = height/2 const animation = tweened(0, { duration: 4000, easing: cubicOut }) const interval = setInterval(() => { animation.set($animation===1 ? 0 : 1) }, 5000) onDestroy(() => clearInterval(interval)) const timeAxisPadding = 40 $: dynamicPaddingForTimeAxis = $animation * timeAxisPadding //we need to squeeze the unwrapped BRG to make room for the time axis $: x2Scale = scaleLinear().domain( [0, 180, 180, 360] ).range( [ dynamicPaddingForTimeAxis/2, //offset the center by half the dynamic padding $animation*halfWidth, -$animation*halfWidth + dynamicPaddingForTimeAxis, //offset the left end of the BRG by the dynamic padding dynamicPaddingForTimeAxis/2, //offset the center by half the dynamic padding ] ) $: y2 = $animation * halfHeight $: thetaScale = scaleLinear().domain( [0, 180, 180, 360] ).range( [0, (1 - $animation)*180, ($animation - 1)*180, 0] ) $: getLineDataFromAngle = (angle, radius=halfWidth) => { const x2 = x2Scale(angle) const theta = thetaScale(angle) / DEG_PER_RAD return { x1: x2 + radius * Math.sin(theta), y1: - radius * Math.cos(theta), x2, y2, } } const circleDegrees:number[] = [] for(let i=180; i<360; ++i) { //push numbers 180 to 359 circleDegrees.push(i) } for(let i=0; i<180; ++i) { //push numbers 0 to 179 circleDegrees.push(i) } circleDegrees.push(179.9) //this is now equal to [180, 181, ..., 359, 0, 1, ..., 179, 179.9] $: topFactor = (12 + 3*$animation) / 16 //make the circle a little offset from the edge $: circlePathRadius = halfWidth * topFactor $: circlePath = circleDegrees.reduce( (d, degree) => { const { x1, y1 } = getLineDataFromAngle(degree, circlePathRadius) d += ` ${x1},${y1}` return d }, "M" ) const ticks:{angle:number,label:string, dy:number}[] = [ {angle:180, label: "180°", dy: 15}, {angle:225, label: "", dy: 0}, {angle:270, label: "270°", dy: -5}, {angle:315, label: "", dy: 0}, {angle:0, label: "0° (360°)", dy: 0}, {angle:45, label: "", dy: 0}, {angle:90, label: "90°", dy: -5}, {angle:135, label: "", dy: 0}, {angle:179.999, label: "180°", dy: 15}, ] $: lineData = ticks.map(t => getLineDataFromAngle(t.angle)) $: ticksWithLabels = ticks.filter(t => t.label.length > 0 //only allow ticks with a label && !(Math.floor(t.angle)===179 && $animation===0) //hide the 179.999 label if the animation is at 0 ) const tickEndDy = -5 $: textData = ticksWithLabels.map(t => { const { x1, y1 } = getLineDataFromAngle(t.angle) return { attr: { x: x1, y: y1, dy: tickEndDy*$animation + (1 - $animation)*t.dy }, label: t.label, } }) $: timeAxisTop = -halfHeight * topFactor </script> <main> <div bind:clientWidth={fullWidth}> <svg width={fullWidth} height={fullHeight} > <g transform={`translate(${fullWidth/2},${fullHeight/2})`}> <g class="grid"> <path d={circlePath}/> {#each lineData as line} <line {...line}/> {/each} <line x1={x2Scale(180)} y1={y2} x2={x2Scale(179.9)} {y2}/> {#each textData as text} <text {...text.attr}>{text.label}</text> {/each} </g> <g class="timeAxis" transform={`translate(${-halfWidth + timeAxisPadding - 10},0)`} opacity={Math.max(0, 10*($animation-0.9))}> <text x={-20} y={timeAxisTop} dy={5} text-anchor="end">Now</text> <line x1={-15} y1={timeAxisTop} x2={0} y2={timeAxisTop}/> <line x1={0} y1={timeAxisTop} x2={0} y2={halfHeight}/> <line x1={-15} y1={halfWidth} x2={0} y2={halfHeight}/> <text x={-20} y={halfHeight} dy={-5} text-anchor="end">5 sec</text> <text x={-20} y={halfHeight} dy={10} text-anchor="end">ago</text> </g> </g> </svg> </div> </main> <style> .grid line, .grid path { fill: none; stroke: gray; stroke-width: 1px; } .grid text { text-anchor: middle; } .timeAxis line { stroke: gray; stroke-width: 2px; } </style>