diff --git a/src/index.md b/src/index.md index 3a619bf..da17fbf 100644 --- a/src/index.md +++ b/src/index.md @@ -1,7 +1,8 @@ ```js const parseTime = d3.timeParse("%b %d, %Y"); const formatYear = d3.utcFormat("%Y"); -const coerceGameData = (d,i) => ({date: parseTime(d.date), year: parseInt(formatYear(parseTime(d.date))), spread: Number(d.spread)}); +const formatDate = d3.utcFormat("%b %d, %Y"); +const coerceGameData = (d,i) => ({date: parseTime(d.date), year: parseInt(formatYear(parseTime(d.date))), spread: Number(d.spread), fav: d.fav, und: d.und, sF: d.sF, sU: d.sU}); const oddsfile = FileAttachment("./data/odds_data.json").json().then((D) => D.map(coerceGameData)); ``` ```js @@ -14,66 +15,120 @@ display(oddsByYear); ```js function oddsPlot(data, {width} = {}) { - // Declare the chart dimensions and margins. - const height = 500; const marginTop = 20; - const marginRight = 30; - const marginBottom = 30; - const marginLeft = 40; + const marginRight = 20; + const marginBottom = 20; + const marginLeft = 20; + const radius = 3; + const padding = 1.5; + const height = + longestString(oddsByYear)[1].length * (radius * 2 + padding) + + marginBottom + + marginTop + + 50; - // Declare the x (horizontal position) scale. - const x = d3.scaleTime([1952,2025], [marginLeft, width-marginRight]); + //d3.max(oddsfile, (d) => (d.spread)); + const color = d3 + .scaleLinear() + .domain([0, d3.max(oddsfile, (d) => d.spread)]) + .range([0, 1]); - // Declare the y (vertical position) scale. - const y = d3.scaleLinear([0, 600], [height - marginBottom, marginTop]); + const x = d3 + .scaleLinear() + .domain(d3.extent(oddsfile, (d) => d.year)) + .range([marginLeft, width - marginRight]); -// Declare the line generator. - const line = d3.line() - .x(d => x(d.year)) - .y(d => y(d.count())) - // .y(d => y(total(d, "year", d.year))); + const svg = d3 + .create("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .attr("style", "max-width: 100%; height: auto;"); - // Create the SVG container. - const svg = d3.create("svg") - .attr("width", width) - .attr("height", height) - .attr("viewBox", [0, 0, width, height]) - .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); + svg + .append("g") + .attr("transform", `translate(0,${height - marginBottom})`) + .call(d3.axisBottom(x).tickSizeOuter(0)); - // Add the x-axis. - svg.append("g") - .attr("transform", `translate(0,${height - marginBottom})`) - .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0)); - - // Add the y-axis, remove the domain line, add grid lines and a label. - svg.append("g") - .attr("transform", `translate(${marginLeft},0)`) - .call(d3.axisLeft(y).ticks(height / 40)) - .call(g => g.select(".domain").remove()) - .call(g => g.selectAll(".tick line").clone() - .attr("x2", width - marginLeft - marginRight) - .attr("stroke-opacity", 0.1)) - - // Append a path for the line. - svg.append("path") - .attr("fill", "none") - .attr("stroke", "steelblue") - .attr("stroke-width", 1.5) - .attr("d", line(data)); + svg + .append("g") + .selectAll() + .data( + dodge(oddsfile, { radius: radius * 2 + padding, x: (d) => x(d.year) }) + ) + .join("circle") + .attr("fill", (d) => d3.interpolateRainbow(color(d.data.spread))) + .attr("cx", (d) => d.x) + .attr("cy", (d) => height - marginBottom - radius - padding - d.y) + .attr("r", radius) + .append("title") + .text((d) => `${formatDate(d.data.date)}\n${d.data.fav}(${d.data.sF}) vs. ${d.data.und}(${d.data.sU})`); return svg.node(); } ``` ```js +function dodge(data, { radius = 1, x = (d) => d } = {}) { + const radius2 = radius ** 2; + const circles = data + .map((d, i, data) => ({ x: +x(d, i, data), data: d })) + .sort((a, b) => a.x - b.x); + const epsilon = 1e-3; + let head = null, + tail = null; + + // Returns true if circle ⟨x,y⟩ intersects with any circle in the queue. + function intersects(x, y) { + let a = head; + while (a) { + if (radius2 - epsilon > (a.x - x) ** 2 + (a.y - y) ** 2) { + return true; + } + a = a.next; + } + return false; + } + + // Place each circle sequentially. + for (const b of circles) { + // Remove circles from the queue that can’t intersect the new circle b. + while (head && head.x < b.x - radius2) head = head.next; + // Choose the minimum non-intersecting tangent. + if (intersects(b.x, (b.y = 0))) { + let a = head; + b.y = Infinity; + do { + let y = a.y + Math.sqrt(radius2 - (a.x - b.x) ** 2); + if (y < b.y && !intersects(b.x, y)) b.y = y; + a = a.next; + } while (a); + } + + // Add b to the queue. + b.next = null; + if (head === null) head = tail = b; + else tail = tail.next = b; + } + + return circles; +} + function total(data, label, find) { return data.reduce(function(count, entry) { return count + (entry[label] === find ? 1 : 0); }, 0); } + +function longestString(map) { + return Array.from(map).sort(function (a, b) { + return b[1].length - a[1].length; + })[0]; +} ``` +
${resize((width) => oddsPlot(oddsByYear, {width}))}
\ No newline at end of file