Skip to content

Commit

Permalink
Currency exchange mini-tool (#191)
Browse files Browse the repository at this point in the history
* Initial pass at exchange rate tool

* Functionally works

* Styling

* Exchange sitemap
  • Loading branch information
Shpigford authored Nov 12, 2024
1 parent 52126d8 commit 2ae708e
Show file tree
Hide file tree
Showing 14 changed files with 832 additions and 4 deletions.
1 change: 1 addition & 0 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def sitemap
.sort_by(&:first)
@industries = Stock.where(kind: "stock").where.not(mic_code: nil).where.not(industry: nil).distinct.pluck(:industry, :country_code).compact.sort_by(&:first)
@sectors = Stock.where(kind: "stock").where.not(mic_code: nil).where.not(sector: nil).distinct.pluck(:sector).compact.sort
@exchange_rate_currencies = Tool::Presenter::ExchangeRateCalculator.new.currency_options

# Paginate stocks
@stocks = Stock.order(name: :asc)
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/tools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def tool_params
# ROI Calculator
:amount_invested, :amount_returned, :investment_period, :investment_length,
# Stock Portfolio Backtest
:benchmark_stock, :investment_amount, :start_date, :end_date, { stocks: [], stock_allocations: [] }
:benchmark_stock, :investment_amount, :start_date, :end_date, { stocks: [], stock_allocations: [] },
# Exchange Rate Calculator
:amount, :from_currency, :to_currency
end
end
40 changes: 40 additions & 0 deletions app/javascript/controllers/exchange_rate_url_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["fromCurrency", "toCurrency"]

connect() {
this.updateURL()
}

updateURL() {
const fromCurrency = this.fromCurrencyTarget.value
const toCurrency = this.toCurrencyTarget.value
const currentPath = window.location.pathname
const pathParts = currentPath.split('/')

// Check if current URL has an amount
const hasAmount = !isNaN(pathParts[pathParts.length - 1])
const currentAmount = hasAmount ? pathParts[pathParts.length - 1] : '1'

const desiredUrl = `/tools/exchange-rate-calculator/${fromCurrency}/${toCurrency}${hasAmount ? `/${currentAmount}` : ''}`

if (currentPath !== desiredUrl) {
history.pushState({}, "", desiredUrl)

// Prevent form submission entirely when URL changes
this.element.addEventListener('submit', this.preventSubmit, { once: true })

// Trigger a Turbo visit instead
Turbo.visit(desiredUrl, { action: 'replace' })
}
}

preventSubmit = (event) => {
event.preventDefault()
}

currencyChanged(event) {
this.updateURL()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { Controller } from "@hotwired/stimulus";
import tailwindColors from "@maybe/tailwindcolors";
import * as d3 from "d3";

export default class extends Controller {
static targets = ["loadingSpinner"]
static values = {
series: { type: Object, default: {} },
data: { type: Array, default: [] }
};

#initialElementWidth = 0;
#initialElementHeight = 0;

connect() {
this.#rememberInitialElementSize();
this.showLoading();
this.#drawGridlines();
this.#drawChart();
this.#drawXAxis();
this.#installTooltip();
this.hideLoading();
}

showLoading() {
if (this.hasLoadingSpinnerTarget) {
this.loadingSpinnerTarget.classList.remove("hidden");
}
}

hideLoading() {
if (this.hasLoadingSpinnerTarget) {
this.loadingSpinnerTarget.classList.add("hidden");
}
}

#data = [];
dataValueChanged(value) {
this.#data = value.map(d => ({
...d,
date: new Date(d.date)
}));
}

#rememberInitialElementSize() {
this.#initialElementWidth = this.element.clientWidth;
this.#initialElementHeight = this.element.clientHeight;
}

get #contentWidth() {
return this.#initialElementWidth - this.#margin.left - this.#margin.right;
}

get #contentHeight() {
return this.#initialElementHeight - this.#margin.top - this.#margin.bottom;
}

get #margin() {
return { top: 0, right: 0, bottom: 40, left: 0 };
}

#drawChart() {
const x = this.#d3XScale;
const y = this.#d3YScale;

// Create gradient
const gradient = this.#d3Svg.append("defs")
.append("linearGradient")
.attr("id", "exchange-rate-gradient")
.attr("x1", "0%").attr("y1", "0%")
.attr("x2", "0%").attr("y2", "100%");

gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", tailwindColors.blue[500])
.attr("stop-opacity", 0.2);

gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", tailwindColors.blue[500])
.attr("stop-opacity", 0);

// Draw the area
this.#d3Content
.append("path")
.datum(this.#data)
.attr("fill", "url(#exchange-rate-gradient)")
.attr("d", d3.area()
.x(d => x(d.date))
.y0(y(d3.min(this.#data, d => d.rate) * 0.999))
.y1(d => y(d.rate))
.curve(d3.curveMonotoneX)
);

// Draw the line
this.#d3Content
.append("path")
.datum(this.#data)
.attr("fill", "none")
.attr("stroke", tailwindColors.blue[500])
.attr("stroke-width", 3)
.attr("stroke-linecap", "round")
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.rate))
.curve(d3.curveMonotoneX)
);
}

#drawGridlines() {
const axisGenerator = d3.axisRight(this.#d3YScale)
.ticks(10)
.tickSize(this.#contentWidth)
.tickFormat("");

const gridlines = this.#d3Content
.append("g")
.attr("class", "d3gridlines")
.call(axisGenerator);

gridlines
.selectAll("line")
.style("stroke", tailwindColors["alpha-black"][500])
.style("stroke-dasharray", "1 8")
.style("stroke-width", "1")
.style("stroke-linecap", "round");

gridlines
.select(".domain")
.remove();
}

#drawXAxis() {
const first = this.#data[0];
const last = this.#data[this.#data.length - 1];

const axisGenerator = d3.axisBottom(this.#d3XScale)
.tickValues([first.date, last.date])
.tickSize(0)
.tickFormat(d3.timeFormat("%B %Y"));

const axis = this.#d3Content
.append("g")
.attr("transform", `translate(0, ${this.#contentHeight - this.#margin.bottom / 2 - 6})`)
.call(axisGenerator);

axis
.select(".domain")
.remove();

axis
.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "14px")
.style("font-weight", "400")
.attr("text-anchor", (_, i) => i === 0 ? "start" : "end");
}

#installTooltip() {
const dot = this.#d3Content
.append("g")
.attr("class", "focus")
.style("display", "none");

dot.append("circle")
.attr("r", 4)
.attr("fill", tailwindColors.blue[500])
.attr("stroke", "white")
.attr("stroke-width", 2);

this.#d3Content
.append("rect")
.attr("width", this.#contentWidth)
.attr("height", this.#contentHeight)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mouseover", () => dot.style("display", null))
.on("mouseout", () => {
dot.style("display", "none");
this.#d3Content.selectAll(".guideline").remove();
this.#d3Tooltip.style("opacity", 0);
})
.on("mousemove", (event) => {
const x = this.#d3XScale;
const d = this.#findDatumByPointer(event);
const dataX = x(d.date);

dot.attr("transform", `translate(${dataX}, ${this.#d3YScale(d.rate)})`);

this.#d3Content.selectAll(".guideline").remove();

this.#d3Content
.insert("line", ":first-child")
.attr("class", "guideline")
.attr("stroke", tailwindColors["alpha-black"][50])
.style("stroke-dasharray", "5")
.style("stroke-width", "2")
.style("stroke-linecap", "round")
.attr("x1", dataX)
.attr("y1", 0 + this.#margin.top)
.attr("x2", dataX)
.attr("y2", this.#contentHeight - this.#margin.bottom);

this.#d3Tooltip
.html(this.#tooltipTemplate(d))
.style("opacity", 1)
.style("z-index", 999)
.style("left", `${event.pageX + 10}px`)
.style("top", `${event.pageY}px`);
});
}

#tooltipTemplate(datum) {
return (`
<div class="mb-1 text-gray-500 font-medium">
${datum.yearMonth}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<svg width="4" height="12">
<rect rx="2" ry="2" class="fill-blue-500" width="4" height="12"></rect>
</svg>
<span class="font-medium">${datum.rate.toFixed(6)}</span>
</div>
</div>
`);
}

#d3TooltipMemo = null;
get #d3Tooltip() {
if (this.#d3TooltipMemo) return this.#d3TooltipMemo;

return this.#d3TooltipMemo = this.#d3Element
.append("div")
.attr("class", "absolute text-sm bg-white border border-alpha-black-100 p-2 rounded-lg shadow-sm")
.style("pointer-events", "none")
.style("opacity", 0);
}

#d3GroupMemo = null;
get #d3Content() {
if (this.#d3GroupMemo) return this.#d3GroupMemo;

return this.#d3GroupMemo = this.#d3Svg
.append("g")
.attr("transform", `translate(${this.#margin.left},${this.#margin.top})`);
}

#d3SvgMemo = null;
get #d3Svg() {
if (this.#d3SvgMemo) return this.#d3SvgMemo;

return this.#d3SvgMemo = this.#d3Element
.append("svg")
.attr("width", this.#initialElementWidth)
.attr("height", this.#initialElementHeight)
.attr("viewBox", [0, 0, this.#initialElementWidth, this.#initialElementHeight]);
}

get #d3Element() {
return d3.select(this.element);
}

get #d3XScale() {
const dateExtent = d3.extent(this.#data, d => d.date);
return d3.scaleTime()
.domain(dateExtent)
.range([0, this.#contentWidth]);
}

get #d3YScale() {
const rates = this.#data.map(d => d.rate);
const min = d3.min(rates);
const max = d3.max(rates);
const padding = (max - min) * 0.1;

return d3.scaleLinear()
.domain([min - padding, max + padding])
.range([this.#contentHeight - this.#margin.bottom, this.#margin.top]);
}

#findDatumByPointer(event) {
const x = this.#d3XScale;
const [xPos] = d3.pointer(event);
const bisectDate = d3.bisector(d => d.date).left;
const date = x.invert(xPos);
const index = bisectDate(this.#data, date, 1);

if (index === 0) return this.#data[0];
if (index >= this.#data.length) return this.#data[this.#data.length - 1];

const d0 = this.#data[index - 1];
const d1 = this.#data[index];
return date - d0.date > d1.date - date ? d1 : d0;
}
}
Loading

0 comments on commit 2ae708e

Please sign in to comment.