mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into zachgoll/plaid-sync-domain-improvements
This commit is contained in:
commit
b8687a7d60
111 changed files with 1337 additions and 318 deletions
|
@ -20,7 +20,6 @@
|
||||||
}
|
}
|
||||||
.pcr-color-palette{
|
.pcr-color-palette{
|
||||||
height: 12em !important;
|
height: 12em !important;
|
||||||
width: 21.5rem !important;
|
|
||||||
}
|
}
|
||||||
.pcr-palette{
|
.pcr-palette{
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
|
|
|
@ -78,14 +78,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility bg-divider {
|
|
||||||
@apply bg-alpha-black-100;
|
|
||||||
|
|
||||||
@variant theme-dark {
|
|
||||||
@apply bg-alpha-white-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility bg-overlay {
|
@utility bg-overlay {
|
||||||
background-color: --alpha(var(--color-gray-100) / 50%);
|
background-color: --alpha(var(--color-gray-100) / 50%);
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility border-divider {
|
||||||
|
@apply border-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
@utility border-subdued {
|
@utility border-subdued {
|
||||||
@apply border-alpha-black-50;
|
@apply border-alpha-black-50;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<% if variant == :divider %>
|
<% if variant == :divider %>
|
||||||
<hr class="border-tertiary my-1">
|
<%= render "shared/ruler", classes: "my-1" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<%= wrapper do %>
|
<%= wrapper do %>
|
||||||
|
|
|
@ -6,6 +6,23 @@ class PagesController < ApplicationController
|
||||||
@balance_sheet = Current.family.balance_sheet
|
@balance_sheet = Current.family.balance_sheet
|
||||||
@accounts = Current.family.accounts.active.with_attached_logo
|
@accounts = Current.family.accounts.active.with_attached_logo
|
||||||
|
|
||||||
|
period_param = params[:cashflow_period]
|
||||||
|
@cashflow_period = if period_param.present?
|
||||||
|
begin
|
||||||
|
Period.from_key(period_param)
|
||||||
|
rescue Period::InvalidKeyError
|
||||||
|
Period.last_30_days
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Period.last_30_days
|
||||||
|
end
|
||||||
|
|
||||||
|
family_currency = Current.family.currency
|
||||||
|
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||||
|
expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||||
|
|
||||||
|
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
||||||
|
|
||||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -31,4 +48,100 @@ class PagesController < ApplicationController
|
||||||
def github_provider
|
def github_provider
|
||||||
Provider::Registry.get_provider(:github)
|
Provider::Registry.get_provider(:github)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
|
||||||
|
nodes = []
|
||||||
|
links = []
|
||||||
|
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
|
||||||
|
|
||||||
|
# Helper to add/find node and return its index
|
||||||
|
add_node = ->(unique_key, display_name, value, percentage, color) {
|
||||||
|
node_indices[unique_key] ||= begin
|
||||||
|
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
||||||
|
nodes.size - 1
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
total_income_val = income_totals.total.to_f.round(2)
|
||||||
|
total_expense_val = expense_totals.total.to_f.round(2)
|
||||||
|
|
||||||
|
# --- Create Central Cash Flow Node ---
|
||||||
|
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
|
||||||
|
|
||||||
|
# --- Process Income Side (Top-level categories only) ---
|
||||||
|
income_totals.category_totals.each do |ct|
|
||||||
|
# Skip subcategories – only include root income categories
|
||||||
|
next if ct.category.parent_id.present?
|
||||||
|
|
||||||
|
val = ct.total.to_f.round(2)
|
||||||
|
next if val.zero?
|
||||||
|
|
||||||
|
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
|
||||||
|
|
||||||
|
node_display_name = ct.category.name
|
||||||
|
node_color = ct.category.color.presence || Category::COLORS.sample
|
||||||
|
|
||||||
|
current_cat_idx = add_node.call(
|
||||||
|
"income_#{ct.category.id}",
|
||||||
|
node_display_name,
|
||||||
|
val,
|
||||||
|
percentage_of_total_income,
|
||||||
|
node_color
|
||||||
|
)
|
||||||
|
|
||||||
|
links << {
|
||||||
|
source: current_cat_idx,
|
||||||
|
target: cash_flow_idx,
|
||||||
|
value: val,
|
||||||
|
color: node_color,
|
||||||
|
percentage: percentage_of_total_income
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Process Expense Side (Top-level categories only) ---
|
||||||
|
expense_totals.category_totals.each do |ct|
|
||||||
|
# Skip subcategories – only include root expense categories to keep Sankey shallow
|
||||||
|
next if ct.category.parent_id.present?
|
||||||
|
|
||||||
|
val = ct.total.to_f.round(2)
|
||||||
|
next if val.zero?
|
||||||
|
|
||||||
|
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
|
||||||
|
|
||||||
|
node_display_name = ct.category.name
|
||||||
|
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||||
|
|
||||||
|
current_cat_idx = add_node.call(
|
||||||
|
"expense_#{ct.category.id}",
|
||||||
|
node_display_name,
|
||||||
|
val,
|
||||||
|
percentage_of_total_expense,
|
||||||
|
node_color
|
||||||
|
)
|
||||||
|
|
||||||
|
links << {
|
||||||
|
source: cash_flow_idx,
|
||||||
|
target: current_cat_idx,
|
||||||
|
value: val,
|
||||||
|
color: node_color,
|
||||||
|
percentage: percentage_of_total_expense
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Process Surplus ---
|
||||||
|
leftover = (total_income_val - total_expense_val).round(2)
|
||||||
|
if leftover.positive?
|
||||||
|
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
|
||||||
|
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
|
||||||
|
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update Cash Flow and Income node percentages (relative to total income)
|
||||||
|
if node_indices["cash_flow_node"]
|
||||||
|
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
|
||||||
|
end
|
||||||
|
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
|
||||||
|
|
||||||
|
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,7 @@ export default class extends Controller {
|
||||||
"validationMessage",
|
"validationMessage",
|
||||||
"selection",
|
"selection",
|
||||||
"colorPickerRadioBtn",
|
"colorPickerRadioBtn",
|
||||||
|
"popup",
|
||||||
];
|
];
|
||||||
|
|
||||||
static values = {
|
static values = {
|
||||||
|
@ -36,6 +37,7 @@ export default class extends Controller {
|
||||||
this.colorInputTarget.reportValidity();
|
this.colorInputTarget.reportValidity();
|
||||||
e.target.open = true;
|
e.target.open = true;
|
||||||
}
|
}
|
||||||
|
this.updatePopupPosition()
|
||||||
});
|
});
|
||||||
|
|
||||||
this.selectedIcon = null;
|
this.selectedIcon = null;
|
||||||
|
@ -43,6 +45,8 @@ export default class extends Controller {
|
||||||
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
||||||
this.colorPickerRadioBtnTarget.checked = true;
|
this.colorPickerRadioBtnTarget.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", this.handleOutsideClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
initPicker() {
|
initPicker() {
|
||||||
|
@ -209,6 +213,7 @@ export default class extends Controller {
|
||||||
this.colorsSectionTarget.classList.add("hidden");
|
this.colorsSectionTarget.classList.add("hidden");
|
||||||
this.paletteSectionTarget.classList.remove("hidden");
|
this.paletteSectionTarget.classList.remove("hidden");
|
||||||
this.pickerSectionTarget.classList.remove("hidden");
|
this.pickerSectionTarget.classList.remove("hidden");
|
||||||
|
this.updatePopupPosition();
|
||||||
this.picker.show();
|
this.picker.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -216,6 +221,7 @@ export default class extends Controller {
|
||||||
this.colorsSectionTarget.classList.remove("hidden");
|
this.colorsSectionTarget.classList.remove("hidden");
|
||||||
this.paletteSectionTarget.classList.add("hidden");
|
this.paletteSectionTarget.classList.add("hidden");
|
||||||
this.pickerSectionTarget.classList.add("hidden");
|
this.pickerSectionTarget.classList.add("hidden");
|
||||||
|
this.updatePopupPosition()
|
||||||
if (this.picker) {
|
if (this.picker) {
|
||||||
this.picker.destroyAndRemove();
|
this.picker.destroyAndRemove();
|
||||||
}
|
}
|
||||||
|
@ -229,6 +235,27 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOutsideClick = (event) => {
|
||||||
|
if (this.detailsTarget.open && !this.detailsTarget.contains(event.target)) {
|
||||||
|
this.detailsTarget.open = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePopupPosition() {
|
||||||
|
const popup = this.popupTarget;
|
||||||
|
popup.style.top = "";
|
||||||
|
popup.style.bottom = "";
|
||||||
|
|
||||||
|
const rect = popup.getBoundingClientRect();
|
||||||
|
const overflow = rect.bottom > window.innerHeight;
|
||||||
|
|
||||||
|
if (overflow) {
|
||||||
|
popup.style.bottom = "0px";
|
||||||
|
} else {
|
||||||
|
popup.style.bottom = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#backgroundColor(color) {
|
#backgroundColor(color) {
|
||||||
return `color-mix(in oklab, ${color} 10%, transparent)`;
|
return `color-mix(in oklab, ${color} 10%, transparent)`;
|
||||||
}
|
}
|
||||||
|
|
44
app/javascript/controllers/preserve_scroll_controller.js
Normal file
44
app/javascript/controllers/preserve_scroll_controller.js
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/*
|
||||||
|
https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i
|
||||||
|
modified to add support for horizontal scrolling
|
||||||
|
|
||||||
|
only requirement is that the element has an id
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static scrollPositions = {}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.preserveScrollBound = this.preserveScroll.bind(this)
|
||||||
|
this.restoreScrollBound = this.restoreScroll.bind(this)
|
||||||
|
|
||||||
|
window.addEventListener("turbo:before-cache", this.preserveScrollBound)
|
||||||
|
window.addEventListener("turbo:before-render", this.restoreScrollBound)
|
||||||
|
window.addEventListener("turbo:render", this.restoreScrollBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
window.removeEventListener("turbo:before-cache", this.preserveScrollBound)
|
||||||
|
window.removeEventListener("turbo:before-render", this.restoreScrollBound)
|
||||||
|
window.removeEventListener("turbo:render", this.restoreScrollBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
preserveScroll() {
|
||||||
|
if (!this.element.id) return
|
||||||
|
|
||||||
|
this.constructor.scrollPositions[this.element.id] = {
|
||||||
|
top: this.element.scrollTop,
|
||||||
|
left: this.element.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreScroll(event) {
|
||||||
|
if (!this.element.id) return
|
||||||
|
|
||||||
|
if (this.constructor.scrollPositions[this.element.id]) {
|
||||||
|
this.element.scrollTop = this.constructor.scrollPositions[this.element.id].top
|
||||||
|
this.element.scrollLeft = this.constructor.scrollPositions[this.element.id].left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
204
app/javascript/controllers/sankey_chart_controller.js
Normal file
204
app/javascript/controllers/sankey_chart_controller.js
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
|
||||||
|
|
||||||
|
// Connects to data-controller="sankey-chart"
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
data: Object,
|
||||||
|
nodeWidth: { type: Number, default: 15 },
|
||||||
|
nodePadding: { type: Number, default: 20 },
|
||||||
|
currencySymbol: { type: String, default: "$" }
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.#draw());
|
||||||
|
this.resizeObserver.observe(this.element);
|
||||||
|
this.#draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#draw() {
|
||||||
|
const { nodes = [], links = [] } = this.dataValue || {};
|
||||||
|
|
||||||
|
if (!nodes.length || !links.length) return;
|
||||||
|
|
||||||
|
// Clear previous SVG
|
||||||
|
d3.select(this.element).selectAll("svg").remove();
|
||||||
|
|
||||||
|
const width = this.element.clientWidth || 600;
|
||||||
|
const height = this.element.clientHeight || 400;
|
||||||
|
|
||||||
|
const svg = d3
|
||||||
|
.select(this.element)
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", width)
|
||||||
|
.attr("height", height);
|
||||||
|
|
||||||
|
const sankeyGenerator = sankey()
|
||||||
|
.nodeWidth(this.nodeWidthValue)
|
||||||
|
.nodePadding(this.nodePaddingValue)
|
||||||
|
.extent([
|
||||||
|
[16, 16],
|
||||||
|
[width - 16, height - 16],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sankeyData = sankeyGenerator({
|
||||||
|
nodes: nodes.map((d) => Object.assign({}, d)),
|
||||||
|
links: links.map((d) => Object.assign({}, d)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define gradients for links
|
||||||
|
const defs = svg.append("defs");
|
||||||
|
|
||||||
|
sankeyData.links.forEach((link, i) => {
|
||||||
|
const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`;
|
||||||
|
|
||||||
|
const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => {
|
||||||
|
let colorStr = nodeColorInput || "var(--color-gray-400)";
|
||||||
|
if (colorStr === "var(--color-success)") {
|
||||||
|
colorStr = "#10A861"; // Hex for --color-green-600
|
||||||
|
}
|
||||||
|
// Add other CSS var to hex mappings here if needed
|
||||||
|
|
||||||
|
if (colorStr.startsWith("var(--")) { // Unmapped CSS var, use as is (likely solid)
|
||||||
|
return colorStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d3Color = d3.color(colorStr);
|
||||||
|
return d3Color ? d3Color.copy({ opacity: opacity }) : "var(--color-gray-400)";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceStopColor = getStopColorWithOpacity(link.source.color);
|
||||||
|
const targetStopColor = getStopColorWithOpacity(link.target.color);
|
||||||
|
|
||||||
|
const gradient = defs.append("linearGradient")
|
||||||
|
.attr("id", gradientId)
|
||||||
|
.attr("gradientUnits", "userSpaceOnUse")
|
||||||
|
.attr("x1", link.source.x1)
|
||||||
|
.attr("x2", link.target.x0);
|
||||||
|
|
||||||
|
gradient.append("stop")
|
||||||
|
.attr("offset", "0%")
|
||||||
|
.attr("stop-color", sourceStopColor);
|
||||||
|
|
||||||
|
gradient.append("stop")
|
||||||
|
.attr("offset", "100%")
|
||||||
|
.attr("stop-color", targetStopColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw links
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("fill", "none")
|
||||||
|
.selectAll("path")
|
||||||
|
.data(sankeyData.links)
|
||||||
|
.join("path")
|
||||||
|
.attr("d", (d) => {
|
||||||
|
const sourceX = d.source.x1;
|
||||||
|
const targetX = d.target.x0;
|
||||||
|
const path = d3.linkHorizontal()({
|
||||||
|
source: [sourceX, d.y0],
|
||||||
|
target: [targetX, d.y1]
|
||||||
|
});
|
||||||
|
return path;
|
||||||
|
})
|
||||||
|
.attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)
|
||||||
|
.attr("stroke-width", (d) => Math.max(1, d.width))
|
||||||
|
.append("title")
|
||||||
|
.text((d) => `${nodes[d.source.index].name} → ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`);
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
const node = svg
|
||||||
|
.append("g")
|
||||||
|
.selectAll("g")
|
||||||
|
.data(sankeyData.nodes)
|
||||||
|
.join("g");
|
||||||
|
|
||||||
|
const cornerRadius = 8;
|
||||||
|
|
||||||
|
node.append("path")
|
||||||
|
.attr("d", (d) => {
|
||||||
|
const x0 = d.x0;
|
||||||
|
const y0 = d.y0;
|
||||||
|
const x1 = d.x1;
|
||||||
|
const y1 = d.y1;
|
||||||
|
const h = y1 - y0;
|
||||||
|
// const w = x1 - x0; // Not directly used in path string, but good for context
|
||||||
|
|
||||||
|
// Dynamic corner radius based on node height, maxed at 8
|
||||||
|
const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));
|
||||||
|
|
||||||
|
const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0);
|
||||||
|
const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0);
|
||||||
|
|
||||||
|
if (isSourceNode) { // Round left corners, flat right for "Total Income"
|
||||||
|
if (h < effectiveCornerRadius * 2) {
|
||||||
|
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||||
|
}
|
||||||
|
return `M ${x0 + effectiveCornerRadius},${y0}
|
||||||
|
L ${x1},${y0}
|
||||||
|
L ${x1},${y1}
|
||||||
|
L ${x0 + effectiveCornerRadius},${y1}
|
||||||
|
Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius}
|
||||||
|
L ${x0},${y0 + effectiveCornerRadius}
|
||||||
|
Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTargetNode) { // Flat left corners, round right for Categories/Surplus
|
||||||
|
if (h < effectiveCornerRadius * 2) {
|
||||||
|
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||||
|
}
|
||||||
|
return `M ${x0},${y0}
|
||||||
|
L ${x1 - effectiveCornerRadius},${y0}
|
||||||
|
Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius}
|
||||||
|
L ${x1},${y1 - effectiveCornerRadius}
|
||||||
|
Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1}
|
||||||
|
L ${x0},${y1} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for intermediate nodes (e.g., "Cash Flow") - draw as a simple sharp-cornered rectangle
|
||||||
|
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||||
|
})
|
||||||
|
.attr("fill", (d) => d.color || "var(--color-gray-400)")
|
||||||
|
.attr("stroke", (d) => {
|
||||||
|
// If a node has an explicit color assigned (even if it's a gray variable),
|
||||||
|
// it gets no stroke. Only truly un-colored nodes (falling back to default fill)
|
||||||
|
// would get a stroke, but our current data structure assigns colors to all nodes.
|
||||||
|
if (d.color) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
return "var(--color-gray-500)"; // Fallback, likely unused with current data
|
||||||
|
});
|
||||||
|
|
||||||
|
const stimulusControllerInstance = this;
|
||||||
|
node
|
||||||
|
.append("text")
|
||||||
|
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
|
||||||
|
.attr("y", (d) => (d.y1 + d.y0) / 2)
|
||||||
|
.attr("dy", "-0.2em")
|
||||||
|
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
|
||||||
|
.attr("class", "text-xs font-medium text-primary fill-current")
|
||||||
|
.each(function (d) {
|
||||||
|
const textElement = d3.select(this);
|
||||||
|
textElement.selectAll("tspan").remove();
|
||||||
|
|
||||||
|
// Node Name on the first line
|
||||||
|
textElement.append("tspan")
|
||||||
|
.text(d.name);
|
||||||
|
|
||||||
|
// Financial details on the second line
|
||||||
|
const financialDetailsTspan = textElement.append("tspan")
|
||||||
|
.attr("x", textElement.attr("x"))
|
||||||
|
.attr("dy", "1.2em")
|
||||||
|
.attr("class", "font-mono text-secondary")
|
||||||
|
.style("font-size", "0.65rem"); // Explicitly set smaller font size
|
||||||
|
|
||||||
|
financialDetailsTspan.append("tspan")
|
||||||
|
.text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
41
app/javascript/controllers/scroll_on_connect_controller.js
Normal file
41
app/javascript/controllers/scroll_on_connect_controller.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
selector: { type: String, default: "[aria-current=\"page\"]" },
|
||||||
|
delay: { type: Number, default: 500 }
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToActiveItem()
|
||||||
|
}, this.delayValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToActiveItem() {
|
||||||
|
const activeItem = this.element?.querySelector(this.selectorValue)
|
||||||
|
|
||||||
|
|
||||||
|
if (!activeItem) return
|
||||||
|
|
||||||
|
const scrollContainer = this.element
|
||||||
|
const containerRect = scrollContainer.getBoundingClientRect()
|
||||||
|
const activeItemRect = activeItem.getBoundingClientRect()
|
||||||
|
|
||||||
|
const scrollPositionX = (activeItemRect.left + scrollContainer.scrollLeft) -
|
||||||
|
(containerRect.width / 2) +
|
||||||
|
(activeItemRect.width / 2)
|
||||||
|
|
||||||
|
const scrollPositionY = (activeItemRect.top + scrollContainer.scrollTop) -
|
||||||
|
(containerRect.height / 2) +
|
||||||
|
(activeItemRect.height / 2)
|
||||||
|
|
||||||
|
|
||||||
|
// Smooth scroll to position
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: Math.max(0, scrollPositionY),
|
||||||
|
left: Math.max(0, scrollPositionX),
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +0,0 @@
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["expenseCategories", "incomeCategories"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.updateCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCategories(event) {
|
|
||||||
const natureField = this.element.querySelector('input[name="account_entry[nature]"]:checked')
|
|
||||||
const natureValue = natureField ? natureField.value : 'outflow'
|
|
||||||
|
|
||||||
if (natureValue === 'inflow') {
|
|
||||||
this.expenseCategoriesTarget.classList.add('hidden')
|
|
||||||
this.incomeCategoriesTarget.classList.remove('hidden')
|
|
||||||
} else {
|
|
||||||
this.expenseCategoriesTarget.classList.remove('hidden')
|
|
||||||
this.incomeCategoriesTarget.classList.add('hidden')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
3
app/javascript/shims/d3-array-default.js
vendored
Normal file
3
app/javascript/shims/d3-array-default.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import * as d3Array from "d3-array-src";
|
||||||
|
export * from "d3-array-src";
|
||||||
|
export default d3Array;
|
3
app/javascript/shims/d3-shape-default.js
vendored
Normal file
3
app/javascript/shims/d3-shape-default.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import * as d3Shape from "d3-shape-src";
|
||||||
|
export * from "d3-shape-src";
|
||||||
|
export default d3Shape;
|
7
app/jobs/security_health_check_job.rb
Normal file
7
app/jobs/security_health_check_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class SecurityHealthCheckJob < ApplicationJob
|
||||||
|
queue_as :scheduled
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Security::HealthChecker.check_all
|
||||||
|
end
|
||||||
|
end
|
|
@ -22,7 +22,8 @@ class MarketDataImporter
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Security.where.not(exchange_operating_mic: nil).find_each do |security|
|
# Import all securities that aren't marked as "offline" (i.e. they're available from the provider)
|
||||||
|
Security.online.find_each do |security|
|
||||||
security.import_provider_prices(
|
security.import_provider_prices(
|
||||||
start_date: get_first_required_price_date(security),
|
start_date: get_first_required_price_date(security),
|
||||||
end_date: end_date,
|
end_date: end_date,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
module Provider::SecurityConcept
|
module Provider::SecurityConcept
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic)
|
Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code)
|
||||||
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
|
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
|
||||||
Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
|
Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
|
||||||
|
|
||||||
|
|
|
@ -94,7 +94,8 @@ class Provider::Synth < Provider
|
||||||
req.params["name"] = symbol
|
req.params["name"] = symbol
|
||||||
req.params["dataset"] = "limited"
|
req.params["dataset"] = "limited"
|
||||||
req.params["country_code"] = country_code if country_code.present?
|
req.params["country_code"] = country_code if country_code.present?
|
||||||
req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present?
|
# Synth uses mic_code, which encompasses both exchange_mic AND exchange_operating_mic (union)
|
||||||
|
req.params["mic_code"] = exchange_operating_mic if exchange_operating_mic.present?
|
||||||
req.params["limit"] = 25
|
req.params["limit"] = 25
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -106,6 +107,7 @@ class Provider::Synth < Provider
|
||||||
name: security.dig("name"),
|
name: security.dig("name"),
|
||||||
logo_url: security.dig("logo_url"),
|
logo_url: security.dig("logo_url"),
|
||||||
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
|
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
|
||||||
|
country_code: security.dig("exchange", "country_code")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -131,7 +133,7 @@ class Provider::Synth < Provider
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
|
def fetch_security_price(symbol:, exchange_operating_mic: nil, date:)
|
||||||
with_provider_response do
|
with_provider_response do
|
||||||
historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date)
|
historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date)
|
||||||
|
|
||||||
|
@ -141,13 +143,13 @@ class Provider::Synth < Provider
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
|
def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:)
|
||||||
with_provider_response do
|
with_provider_response do
|
||||||
params = {
|
params = {
|
||||||
start_date: start_date,
|
start_date: start_date,
|
||||||
end_date: end_date,
|
end_date: end_date,
|
||||||
operating_mic_code: exchange_operating_mic
|
operating_mic_code: exchange_operating_mic
|
||||||
}
|
}.compact
|
||||||
|
|
||||||
data = paginate(
|
data = paginate(
|
||||||
"#{base_url}/tickers/#{symbol}/open-close",
|
"#{base_url}/tickers/#{symbol}/open-close",
|
||||||
|
|
|
@ -9,6 +9,8 @@ class Security < ApplicationRecord
|
||||||
validates :ticker, presence: true
|
validates :ticker, presence: true
|
||||||
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
|
validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }
|
||||||
|
|
||||||
|
scope :online, -> { where(offline: false) }
|
||||||
|
|
||||||
def current_price
|
def current_price
|
||||||
@current_price ||= find_or_fetch_price
|
@current_price ||= find_or_fetch_price
|
||||||
return nil if @current_price.nil?
|
return nil if @current_price.nil?
|
||||||
|
@ -21,13 +23,10 @@ class Security < ApplicationRecord
|
||||||
name: name,
|
name: name,
|
||||||
logo_url: logo_url,
|
logo_url: logo_url,
|
||||||
exchange_operating_mic: exchange_operating_mic,
|
exchange_operating_mic: exchange_operating_mic,
|
||||||
|
country_code: country_code
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_prices?
|
|
||||||
exchange_operating_mic.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def upcase_symbols
|
def upcase_symbols
|
||||||
self.ticker = ticker.upcase
|
self.ticker = ticker.upcase
|
||||||
|
|
120
app/models/security/health_checker.rb
Normal file
120
app/models/security/health_checker.rb
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
# There are hundreds of thousands of market securities that Maybe must handle.
|
||||||
|
# Due to the always-changing nature of the market, the health checker is responsible
|
||||||
|
# for periodically checking active securities to ensure we can still fetch prices for them.
|
||||||
|
#
|
||||||
|
# Each security goes through some basic health checks. If failed, this class is responsible for:
|
||||||
|
# - Marking failed attempts and incrementing the failed attempts counter
|
||||||
|
# - Marking the security offline if enough consecutive failed checks occur
|
||||||
|
# - When we move a security "offline", delete all prices for that security as we assume they are bad data
|
||||||
|
#
|
||||||
|
# The health checker is run daily through SecurityHealthCheckJob (see config/schedule.yml), but not all
|
||||||
|
# securities will be checked every day (we run in batches)
|
||||||
|
class Security::HealthChecker
|
||||||
|
MAX_CONSECUTIVE_FAILURES = 5
|
||||||
|
HEALTH_CHECK_INTERVAL = 7.days
|
||||||
|
DAILY_BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def check_all
|
||||||
|
# No daily limit for unchecked securities (they are prioritized)
|
||||||
|
never_checked_scope.find_each do |security|
|
||||||
|
new(security).run_check
|
||||||
|
end
|
||||||
|
|
||||||
|
# Daily limit for checked securities
|
||||||
|
due_for_check_scope.limit(DAILY_BATCH_SIZE).each do |security|
|
||||||
|
new(security).run_check
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# If a security has never had a health check, we prioritize it, regardless of batch size
|
||||||
|
def never_checked_scope
|
||||||
|
Security.where(last_health_check_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Any securities not checked for 30 days are due
|
||||||
|
# We only process the batch size, which means some "due" securities will not be checked today
|
||||||
|
# This is by design, to prevent all securities from coming due at the same time
|
||||||
|
def due_for_check_scope
|
||||||
|
Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago)
|
||||||
|
.order(last_health_check_at: :asc)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(security)
|
||||||
|
@security = security
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_check
|
||||||
|
Rails.logger.info("Running health check for #{security.ticker}")
|
||||||
|
|
||||||
|
if latest_provider_price
|
||||||
|
handle_success
|
||||||
|
else
|
||||||
|
handle_failure
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Sentry.capture_exception(e) do |scope|
|
||||||
|
scope.set_tags(security_id: @security.id)
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
security.update!(last_health_check_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :security
|
||||||
|
|
||||||
|
def provider
|
||||||
|
Security.provider
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_provider_price
|
||||||
|
return nil unless provider.present?
|
||||||
|
|
||||||
|
response = provider.fetch_security_price(
|
||||||
|
symbol: security.ticker,
|
||||||
|
exchange_operating_mic: security.exchange_operating_mic,
|
||||||
|
date: Date.current
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil unless response.success?
|
||||||
|
|
||||||
|
response.data.price
|
||||||
|
end
|
||||||
|
|
||||||
|
# On success, reset any failure counters and ensure it is "online"
|
||||||
|
def handle_success
|
||||||
|
security.update!(
|
||||||
|
offline: false,
|
||||||
|
failed_fetch_count: 0,
|
||||||
|
failed_fetch_at: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_failure
|
||||||
|
new_failure_count = security.failed_fetch_count.to_i + 1
|
||||||
|
new_failure_at = Time.current
|
||||||
|
|
||||||
|
if new_failure_count > MAX_CONSECUTIVE_FAILURES
|
||||||
|
convert_to_offline_security!
|
||||||
|
else
|
||||||
|
security.update!(
|
||||||
|
failed_fetch_count: new_failure_count,
|
||||||
|
failed_fetch_at: new_failure_at
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The "offline" state tells our MarketDataImporter (daily cron) to skip this security when fetching prices
|
||||||
|
def convert_to_offline_security!
|
||||||
|
Security.transaction do
|
||||||
|
security.update!(
|
||||||
|
offline: true,
|
||||||
|
failed_fetch_count: MAX_CONSECUTIVE_FAILURES + 1,
|
||||||
|
failed_fetch_at: Time.current
|
||||||
|
)
|
||||||
|
security.prices.delete_all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,9 +10,14 @@ module Security::Provided
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
|
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||||
return [] if symbol.blank? || symbol.length < 2
|
return [] if provider.nil? || symbol.blank?
|
||||||
|
|
||||||
response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic)
|
params = {
|
||||||
|
country_code: country_code,
|
||||||
|
exchange_operating_mic: exchange_operating_mic
|
||||||
|
}.compact_blank
|
||||||
|
|
||||||
|
response = provider.search_securities(symbol, **params)
|
||||||
|
|
||||||
if response.success?
|
if response.success?
|
||||||
response.data.map do |provider_security|
|
response.data.map do |provider_security|
|
||||||
|
@ -22,6 +27,7 @@ module Security::Provided
|
||||||
name: provider_security.name,
|
name: provider_security.name,
|
||||||
logo_url: provider_security.logo_url,
|
logo_url: provider_security.logo_url,
|
||||||
exchange_operating_mic: provider_security.exchange_operating_mic,
|
exchange_operating_mic: provider_security.exchange_operating_mic,
|
||||||
|
country_code: provider_security.country_code
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
@ -37,7 +43,11 @@ module Security::Provided
|
||||||
|
|
||||||
# Make sure we have a data provider before fetching
|
# Make sure we have a data provider before fetching
|
||||||
return nil unless provider.present?
|
return nil unless provider.present?
|
||||||
response = provider.fetch_security_price(self, date: date)
|
response = provider.fetch_security_price(
|
||||||
|
symbol: ticker,
|
||||||
|
exchange_operating_mic: exchange_operating_mic,
|
||||||
|
date: date
|
||||||
|
)
|
||||||
|
|
||||||
return nil unless response.success? # Provider error
|
return nil unless response.success? # Provider error
|
||||||
|
|
||||||
|
|
|
@ -1,48 +1,156 @@
|
||||||
class Security::Resolver
|
class Security::Resolver
|
||||||
def initialize(symbol, exchange_operating_mic: nil, country_code: nil)
|
def initialize(symbol, exchange_operating_mic: nil, country_code: nil)
|
||||||
@symbol = symbol
|
@symbol = validate_symbol!(symbol)
|
||||||
@exchange_operating_mic = exchange_operating_mic
|
@exchange_operating_mic = exchange_operating_mic
|
||||||
@country_code = country_code
|
@country_code = country_code
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attempts several paths to resolve a security:
|
||||||
|
# 1. Exact match in DB
|
||||||
|
# 2. Search provider for an exact match
|
||||||
|
# 3. Search provider for close match, ranked by relevance
|
||||||
|
# 4. Create offline security if no match is found in either DB or provider
|
||||||
def resolve
|
def resolve
|
||||||
return nil unless symbol
|
return nil if symbol.blank?
|
||||||
|
|
||||||
exact_match = Security.find_by(
|
exact_match_from_db ||
|
||||||
ticker: symbol,
|
exact_match_from_provider ||
|
||||||
exchange_operating_mic: exchange_operating_mic
|
close_match_from_provider ||
|
||||||
)
|
offline_security
|
||||||
|
|
||||||
exact_match if exact_match.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
attr_reader :symbol, :exchange_operating_mic, :country_code
|
attr_reader :symbol, :exchange_operating_mic, :country_code
|
||||||
|
|
||||||
def fetch_from_provider
|
def validate_symbol!(symbol)
|
||||||
return nil unless Security.provider.present?
|
raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank?
|
||||||
|
symbol.strip.upcase
|
||||||
|
end
|
||||||
|
|
||||||
result = Security.search_provider(
|
def offline_security
|
||||||
symbol,
|
security = Security.find_or_initialize_by(
|
||||||
exchange_operating_mic: exchange_operating_mic
|
ticker: symbol,
|
||||||
|
exchange_operating_mic: exchange_operating_mic,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil unless result.success?
|
security.assign_attributes(
|
||||||
|
country_code: country_code,
|
||||||
|
offline: true # This tells us that we shouldn't try to fetch prices later
|
||||||
|
)
|
||||||
|
|
||||||
selection = if exchange_operating_mic.present?
|
security.save!
|
||||||
result.data.find do |s|
|
|
||||||
s.ticker == symbol && s.exchange_operating_mic == exchange_operating_mic
|
security
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def exact_match_from_db
|
||||||
|
Security.find_by(
|
||||||
|
{
|
||||||
|
ticker: symbol,
|
||||||
|
exchange_operating_mic: exchange_operating_mic,
|
||||||
|
country_code: country_code.presence
|
||||||
|
}.compact
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# If provided a ticker + exchange (and optionally, a country code), we can find exact matches
|
||||||
|
def exact_match_from_provider
|
||||||
|
# Without an exchange, we can never know if we have an exact match
|
||||||
|
return nil unless exchange_operating_mic.present?
|
||||||
|
|
||||||
|
match = provider_search_result.find do |s|
|
||||||
|
ticker_matches = s.ticker.upcase.to_s == symbol.upcase.to_s
|
||||||
|
exchange_matches = s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s
|
||||||
|
|
||||||
|
if country_code && exchange_operating_mic
|
||||||
|
ticker_matches && exchange_matches && s.country_code.upcase.to_s == country_code.upcase.to_s
|
||||||
else
|
else
|
||||||
result.data.sort_by
|
ticker_matches && exchange_matches
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
unless selection.present?
|
return nil unless match
|
||||||
|
|
||||||
|
find_or_create_provider_match!(match)
|
||||||
end
|
end
|
||||||
|
|
||||||
selection
|
def close_match_from_provider
|
||||||
|
filtered_candidates = provider_search_result
|
||||||
|
|
||||||
|
# If a country code is specified, we MUST find a match with the same code
|
||||||
|
if country_code.present?
|
||||||
|
filtered_candidates = filtered_candidates.select { |s| s.country_code.upcase.to_s == country_code.upcase.to_s }
|
||||||
end
|
end
|
||||||
|
|
||||||
def
|
# 1. Prefer exact exchange_operating_mic matches (if one was provided)
|
||||||
|
# 2. Rank by country relevance (lower index in the list is more relevant)
|
||||||
|
# 3. Rank by exchange_operating_mic relevance (lower index in the list is more relevant)
|
||||||
|
sorted_candidates = filtered_candidates.sort_by do |s|
|
||||||
|
[
|
||||||
|
exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1,
|
||||||
|
sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length,
|
||||||
|
sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
match = sorted_candidates.first
|
||||||
|
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
find_or_create_provider_match!(match)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_provider_match!(match)
|
||||||
|
security = Security.find_or_initialize_by(
|
||||||
|
ticker: match.ticker,
|
||||||
|
exchange_operating_mic: match.exchange_operating_mic,
|
||||||
|
)
|
||||||
|
|
||||||
|
security.country_code = match.country_code
|
||||||
|
security.save!
|
||||||
|
|
||||||
|
security
|
||||||
|
end
|
||||||
|
|
||||||
|
def provider_search_result
|
||||||
|
params = {
|
||||||
|
exchange_operating_mic: exchange_operating_mic,
|
||||||
|
country_code: country_code
|
||||||
|
}.compact_blank
|
||||||
|
|
||||||
|
@provider_search_result ||= Security.search_provider(symbol, **params)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Non-exhaustive list of common country codes for help in choosing "close" matches
|
||||||
|
# These are generally sorted by market cap.
|
||||||
|
def sorted_country_codes_by_relevance
|
||||||
|
%w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Non-exhaustive list of common exchange operating MICs for help in choosing "close" matches
|
||||||
|
# This is very US-centric since our prices provider and user base is a majority US-based
|
||||||
|
def sorted_exchange_operating_mics_by_relevance
|
||||||
|
[
|
||||||
|
"XNYS", # New York Stock Exchange
|
||||||
|
"XNAS", # NASDAQ Stock Market
|
||||||
|
"XOTC", # OTC Markets Group (OTC Link)
|
||||||
|
"OTCM", # OTC Markets Group
|
||||||
|
"OTCN", # OTC Bulletin Board
|
||||||
|
"OTCI", # OTC International
|
||||||
|
"OPRA", # Options Price Reporting Authority
|
||||||
|
"MEMX", # Members Exchange
|
||||||
|
"IEXA", # IEX All-Market
|
||||||
|
"IEXG", # IEX Growth Market
|
||||||
|
"EDXM", # Cboe EDGX Exchange (Equities)
|
||||||
|
"XCME", # CME Group (Derivatives)
|
||||||
|
"XCBT", # Chicago Board of Trade
|
||||||
|
"XPUS", # Nasdaq PSX (U.S.)
|
||||||
|
"XPSE", # Nasdaq PHLX (U.S.)
|
||||||
|
"XTRD", # Nasdaq TRF (Trade Reporting Facility)
|
||||||
|
"XTXD", # FINRA TRACE (Trade Reporting)
|
||||||
|
"XARC", # NYSE Arca
|
||||||
|
"XBOX", # BOX Options Exchange
|
||||||
|
"XBXO" # BZX Options (Cboe)
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class Security::SynthComboboxOption
|
class Security::SynthComboboxOption
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
|
|
||||||
attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic
|
attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code
|
||||||
|
|
||||||
def id
|
def id
|
||||||
"#{symbol}|#{exchange_operating_mic}" # submitted by combobox as value
|
"#{symbol}|#{exchange_operating_mic}" # submitted by combobox as value
|
||||||
|
|
|
@ -129,9 +129,9 @@ class TradeBuilder
|
||||||
def security
|
def security
|
||||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||||
|
|
||||||
Security.find_or_create_by!(
|
Security::Resolver.new(
|
||||||
ticker: ticker_symbol,
|
ticker_symbol,
|
||||||
exchange_operating_mic: exchange_operating_mic
|
exchange_operating_mic: exchange_operating_mic
|
||||||
)
|
).resolve
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -76,42 +76,25 @@ class TradeImport < Import
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_or_create_security(ticker:, exchange_operating_mic:)
|
def find_or_create_security(ticker: nil, exchange_operating_mic: nil)
|
||||||
# Normalize empty string to nil for consistency
|
return nil unless ticker.present?
|
||||||
exchange_operating_mic = nil if exchange_operating_mic.blank?
|
|
||||||
|
|
||||||
# First try to find an exact match in our DB, or if no exchange_operating_mic is provided, find by ticker only
|
# Avoids resolving the same security over and over again (resolver potentially makes network calls)
|
||||||
internal_security = if exchange_operating_mic.present?
|
@security_cache ||= {}
|
||||||
Security.find_by(ticker:, exchange_operating_mic:)
|
|
||||||
else
|
|
||||||
Security.find_by(ticker:)
|
|
||||||
end
|
|
||||||
|
|
||||||
return internal_security if internal_security.present?
|
cache_key = [ ticker, exchange_operating_mic ].compact.join(":")
|
||||||
|
|
||||||
# If security prices provider isn't properly configured or available, create with nil exchange_operating_mic
|
security = @security_cache[cache_key]
|
||||||
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) unless Security.provider.present?
|
|
||||||
|
|
||||||
# Cache provider responses so that when we're looping through rows and importing,
|
return security if security.present?
|
||||||
# we only hit our provider for the unique combinations of ticker / exchange_operating_mic
|
|
||||||
cache_key = [ ticker, exchange_operating_mic ]
|
|
||||||
@provider_securities_cache ||= {}
|
|
||||||
|
|
||||||
provider_security = @provider_securities_cache[cache_key] ||= begin
|
security = Security::Resolver.new(
|
||||||
Security.search_provider(
|
|
||||||
ticker,
|
ticker,
|
||||||
exchange_operating_mic: exchange_operating_mic
|
exchange_operating_mic: exchange_operating_mic.presence
|
||||||
).first
|
).resolve
|
||||||
end
|
|
||||||
|
|
||||||
return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) if provider_security.nil?
|
@security_cache[cache_key] = security
|
||||||
|
|
||||||
Security.find_or_create_by!(ticker: provider_security[:ticker]&.upcase, exchange_operating_mic: provider_security[:exchange_operating_mic]&.upcase) do |security|
|
security
|
||||||
security.name = provider_security[:name]
|
|
||||||
security.country_code = provider_security[:country_code]
|
|
||||||
security.logo_url = provider_security[:logo_url]
|
|
||||||
security.exchange_acronym = provider_security[:exchange_acronym]
|
|
||||||
security.exchange_mic = provider_security[:exchange_mic]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -50,7 +50,6 @@
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="ml-auto text-right grow h-10">
|
<div class="ml-auto text-right grow h-10">
|
||||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
<div class="w-6 h-px bg-loader"></div>
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
|
|
|
@ -11,9 +11,12 @@
|
||||||
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
|
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-container rounded-md">
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
<% accounts.each do |account| %>
|
<% accounts.each_with_index do |account, index| %>
|
||||||
<%= render account %>
|
<%= render account %>
|
||||||
|
<% unless index == accounts.count - 1 %>
|
||||||
|
<%= render "shared/ruler" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -75,7 +75,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="rounded-tl-lg rounded-tr-lg bg-container border-alpha-black-25 shadow-xs">
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
|
<% calculator = Balance::TrendCalculator.new(@account.balances) %>
|
||||||
|
|
||||||
|
@ -85,7 +84,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||||
<%= render "shared/pagination", pagy: @pagy %>
|
<%= render "shared/pagination", pagy: @pagy %>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-1.5 mb-3 gap-1">
|
<div class="flex h-1.5 mb-3 gap-1">
|
||||||
<% budget.income_category_totals.each do |category_total| %>
|
<% budget.income_category_totals.each do |category_total| %>
|
||||||
<div class="h-full rounded-xs" style="background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%"></div>
|
<div class="h-full rounded-full" style="background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-1.5 mb-3 gap-1">
|
<div class="flex h-1.5 mb-3 gap-1">
|
||||||
<% budget.expense_category_totals.each do |category_total| %>
|
<% budget.expense_category_totals.each do |category_total| %>
|
||||||
<div class="h-full rounded-xs" style="background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%"></div>
|
<div class="h-full rounded-full" style="background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -34,9 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px w-full bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
|
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
<p><%= categories.count %></p>
|
<p><%= categories.count %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shadow-border-xs rounded-md bg-container">
|
<div class="shadow-border-xs rounded-lg bg-container">
|
||||||
<div class="overflow-hidden rounded-md">
|
<div class="overflow-hidden rounded-lg">
|
||||||
<% Category::Group.for(categories).each_with_index do |group, idx| %>
|
<% Category::Group.for(categories).each_with_index do |group, idx| %>
|
||||||
<%= render group.category %>
|
<%= render group.category %>
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% unless idx == Category::Group.for(categories).count - 1 %>
|
<% unless idx == Category::Group.for(categories).count - 1 %>
|
||||||
<%= render "categories/ruler" %>
|
<%= render "shared/ruler" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,20 +2,20 @@
|
||||||
|
|
||||||
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
|
<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>">
|
||||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||||
<section class="space-y-4 ">
|
<section class="space-y-4">
|
||||||
<div class="w-fit mx-auto relative">
|
<div class="w-fit mx-auto relative">
|
||||||
<%= render partial: "color_avatar", locals: { category: category } %>
|
<%= render partial: "color_avatar", locals: { category: category } %>
|
||||||
|
|
||||||
<details data-category-target="details">
|
<details data-category-target="details" data-action="mousedown->category#handleOutsideClick">
|
||||||
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary">
|
<summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary">
|
||||||
<%= icon("pen", size: "sm") %>
|
<%= icon("pen", size: "sm") %>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="fixed ml-8 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit">
|
<div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-category-target="popup">
|
||||||
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
<div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>">
|
||||||
<div data-category-target="pickerSection"></div>
|
<div data-category-target="pickerSection"></div>
|
||||||
<h4 class="text-gray-500 text-sm">Color</h4>
|
<h4 class="text-gray-500 text-sm">Color</h4>
|
||||||
<div class="flex gap-2 items-center" data-category-target="colorsSection">
|
<div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-category-target="colorsSection">
|
||||||
<% Category::COLORS.each do |color| %>
|
<% Category::COLORS.each do |color| %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
<%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
|
||||||
|
@ -40,7 +40,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 justify-center flex-col w-87">
|
<div class="flex flex-wrap gap-2 justify-center flex-col w-auto md:w-87">
|
||||||
<h4 class="text-secondary text-sm">Icon</h4>
|
<h4 class="text-secondary text-sm">Icon</h4>
|
||||||
<div class="flex flex-wrap gap-0.5">
|
<div class="flex flex-wrap gap-0.5">
|
||||||
<% Category.icon_codes.each do |icon| %>
|
<% Category.icon_codes.each do |icon| %>
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
<div class="bg-container">
|
|
||||||
<div class="h-px bg-alpha-black-50 ml-4 mr-6"></div>
|
|
||||||
</div>
|
|
|
@ -22,7 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<% if @categories.any? %>
|
<% if @categories.any? %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% if @categories.incomes.any? %>
|
<% if @categories.incomes.any? %>
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="border-tertiary">
|
<%= render "shared/ruler", classes: "my-2" %>
|
||||||
|
|
||||||
<div class="relative p-1.5 w-full">
|
<div class="relative p-1.5 w-full">
|
||||||
<% if @transaction.category %>
|
<% if @transaction.category %>
|
||||||
|
|
|
@ -9,6 +9,8 @@
|
||||||
variant: "icon",
|
variant: "icon",
|
||||||
icon: "menu",
|
icon: "menu",
|
||||||
href: path,
|
href: path,
|
||||||
|
frame: chat_frame,
|
||||||
|
text: "All chats"
|
||||||
) %>
|
) %>
|
||||||
|
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
|
@ -24,7 +26,8 @@
|
||||||
variant: "link",
|
variant: "link",
|
||||||
text: "Edit chat title",
|
text: "Edit chat title",
|
||||||
href: edit_chat_path(chat, ctx: "chat"),
|
href: edit_chat_path(chat, ctx: "chat"),
|
||||||
icon: "pencil", data: { turbo_frame: dom_id(chat, "title") }) %>
|
icon: "pencil",
|
||||||
|
frame: dom_id(chat, "title")) %>
|
||||||
|
|
||||||
<% menu.with_item(
|
<% menu.with_item(
|
||||||
variant: "button",
|
variant: "button",
|
||||||
|
|
|
@ -2,32 +2,31 @@
|
||||||
<%= turbo_frame_tag chat_frame do %>
|
<%= turbo_frame_tag chat_frame do %>
|
||||||
<div class="flex flex-col h-full md:p-4">
|
<div class="flex flex-col h-full md:p-4">
|
||||||
<% if @chats.any? %>
|
<% if @chats.any? %>
|
||||||
<nav class="mb-6">
|
|
||||||
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
|
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
|
||||||
variant: "icon",
|
|
||||||
icon: "arrow-left",
|
|
||||||
href: back_path,
|
|
||||||
) %>
|
|
||||||
</nav>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="grow flex flex-col">
|
<div class="grow flex flex-col">
|
||||||
<% if @chats.any? %>
|
<div class="flex items-center justify-between my-6">
|
||||||
<h1 class="text-xl font-medium mb-6">Chats</h1>
|
<h1 class="text-xl font-medium">Chats</h1>
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
id: "new-chat",
|
||||||
|
icon: "plus",
|
||||||
|
variant: "icon",
|
||||||
|
href: new_chat_path,
|
||||||
|
frame: chat_frame,
|
||||||
|
text: "New chat"
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
<div class="space-y-2 px-0.5">
|
<div class="space-y-2 px-0.5">
|
||||||
<%= render @chats %>
|
<%= render @chats %>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
<div class="grow flex flex-col">
|
||||||
<h1 class="sr-only">Chats</h1>
|
<h1 class="sr-only">Chats</h1>
|
||||||
<div class="mt-auto py-8">
|
<div class="mt-auto py-8">
|
||||||
<%= render "chats/ai_greeting" %>
|
<%= render "chats/ai_greeting" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render "messages/chat_form" %>
|
<%= render "messages/chat_form" %>
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= turbo_frame_tag chat_frame do %>
|
<%= turbo_frame_tag chat_frame do %>
|
||||||
<div class="flex flex-col h-full md:px-4 md:pb-4">
|
<div class="flex flex-col h-full md:p-4">
|
||||||
<%= render "chats/chat_nav", chat: @chat %>
|
<%= render "chats/chat_nav", chat: @chat %>
|
||||||
|
|
||||||
<div class="mt-auto py-8">
|
<div class="mt-auto py-8">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (account:, url:) %>
|
<%# locals: (account:, url:) %>
|
||||||
|
|
||||||
<%= render "accounts/form", account: account, url: url do |form| %>
|
<%= render "accounts/form", account: account, url: url do |form| %>
|
||||||
<hr class="my-4">
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.fields_for :accountable do |credit_card_form| %>
|
<%= form.fields_for :accountable do |credit_card_form| %>
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-container shadow-border-xs rounded-md divide-y divide-alpha-black-50">
|
<div class="bg-container shadow-border-xs rounded-lg">
|
||||||
<%= content %>
|
<%= content %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (family_merchant:) %>
|
<%# locals: (family_merchant:) %>
|
||||||
|
|
||||||
<div class="flex justify-between items-center p-4 bg-white">
|
<div class="flex justify-between items-center p-4 bg-container">
|
||||||
<div class="flex w-full items-center gap-2.5">
|
<div class="flex w-full items-center gap-2.5">
|
||||||
<% if family_merchant.logo_url %>
|
<% if family_merchant.logo_url %>
|
||||||
<div class="w-8 h-8 rounded-full flex justify-center items-center">
|
<div class="w-8 h-8 rounded-full flex justify-center items-center">
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
<div class="bg-container">
|
|
||||||
<div class="h-px bg-alpha-black-50 ml-14 mr-6"></div>
|
|
||||||
</div>
|
|
|
@ -9,7 +9,7 @@
|
||||||
) %>
|
) %>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<% if @merchants.any? %>
|
<% if @merchants.any? %>
|
||||||
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
||||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||||
|
@ -18,9 +18,9 @@
|
||||||
<p><%= @merchants.count %></p>
|
<p><%= @merchants.count %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border border-alpha-black-25 rounded-md bg-container shadow-border-xs">
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
<div class="overflow-hidden rounded-md">
|
<div class="overflow-hidden rounded-lg">
|
||||||
<%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "family_merchants/ruler" %>
|
<%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "shared/ruler" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>
|
|
|
@ -13,7 +13,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl bg-container-inset p-1">
|
<div class="bg-container-inset rounded-xl p-1">
|
||||||
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
|
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
|
||||||
<%= tag.p t(".name"), class: "col-span-4" %>
|
<%= tag.p t(".name"), class: "col-span-4" %>
|
||||||
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
|
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
|
||||||
|
@ -22,13 +22,12 @@
|
||||||
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
|
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-container shadow-border-xs">
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
<%= render "holdings/cash", account: @account %>
|
<%= render "holdings/cash", account: @account %>
|
||||||
|
<%= render "shared/ruler" %>
|
||||||
<%= render "holdings/ruler" %>
|
|
||||||
|
|
||||||
<% if @account.current_holdings.any? %>
|
<% if @account.current_holdings.any? %>
|
||||||
<%= render partial: "holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %>
|
<%= render partial: "holdings/holding", collection: @account.current_holdings, spacer_template: "shared/ruler" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-4 border-b last:border-b-0 border-alpha-black-50">
|
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-4">
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<%= link_to import_path(import), class: "text-sm text-primary hover:underline" do %>
|
<%= link_to import_path(import), class: "text-sm text-primary hover:underline" do %>
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
<% import.dry_run.each do |key, count| %>
|
<% import.dry_run.each do |key, count| %>
|
||||||
<% resource = dry_run_resource(key) %>
|
<% resource = dry_run_resource(key) %>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2 bg-container px-5 py-3 rounded-tl-lg rounded-tr-lg">
|
<div class="flex items-center justify-between gap-2 bg-container px-5 py-3 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %>
|
<%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %>
|
||||||
<%= icon resource.icon, color: "current" %>
|
<%= icon resource.icon, color: "current" %>
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if key != import.dry_run.keys.last %>
|
<% if key != import.dry_run.keys.last %>
|
||||||
<div class="h-px bg-alpha-black-50 ml-14 mr-5"></div>
|
<%= render "shared/ruler" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (headers: [], rows: [], caption: nil) %>
|
<%# locals: (headers: [], rows: [], caption: nil) %>
|
||||||
<div class="bg-container-inset rounded-xl overflow-hidden mx-1 md:mx-auto p-3 pl-2 pr-2">
|
<div class="bg-container-inset rounded-xl overflow-hidden md:mx-auto p-4">
|
||||||
<% if caption %>
|
<% if caption %>
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<div class="text-gray-500 mr-2">
|
<div class="text-gray-500 mr-2">
|
||||||
|
@ -8,16 +8,15 @@
|
||||||
<h2 class="text-sm text-gray-500 font-medium"><%= caption %></h2>
|
<h2 class="text-sm text-gray-500 font-medium"><%= caption %></h2>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="overflow-x-auto -webkit-overflow-scrolling-touch">
|
<div class="inline-block min-w-fit sm:w-full rounded-lg shadow-border-xs text-sm bg-container">
|
||||||
<div class="inline-block min-w-fit sm:w-full border border-secondary rounded-md shadow-border-xs text-sm bg-container">
|
<table class="min-w-full">
|
||||||
<table class="min-w-full divide-y divide-alpha-black-200">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<% headers.each_with_index do |header, index| %>
|
<% headers.each_with_index do |header, index| %>
|
||||||
<th class="
|
<th class="
|
||||||
bg-container-inset px-3 py-2.5 font-medium text-left whitespace-nowrap
|
bg-container-inset px-3 py-2 font-medium border-b border-b-alpha-black-200 text-left whitespace-nowrap
|
||||||
<%= index == 0 ? "rounded-tl-md" : "" %>
|
<%= index == 0 ? "rounded-tl-lg" : "" %>
|
||||||
<%= index == headers.length - 1 ? "rounded-tr-md" : "" %>
|
<%= index == headers.length - 1 ? "rounded-tr-lg" : "" %>
|
||||||
<%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %>
|
<%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %>
|
||||||
">
|
">
|
||||||
<%= header %>
|
<%= header %>
|
||||||
|
@ -25,12 +24,12 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-alpha-black-200">
|
<tbody class="">
|
||||||
<% rows.each_with_index do |row, row_index| %>
|
<% rows.each_with_index do |row, row_index| %>
|
||||||
<tr>
|
<tr>
|
||||||
<% row.each_with_index do |(header, value), col_index| %>
|
<% row.each_with_index do |(header, value), col_index| %>
|
||||||
<td class="
|
<td class="
|
||||||
px-3 py-2.5 whitespace-nowrap text-left
|
px-3 py-2 whitespace-nowrap text-left
|
||||||
<%= col_index < row.length - 1 ? "border-r border-r-alpha-black-200" : "" %>
|
<%= col_index < row.length - 1 ? "border-r border-r-alpha-black-200" : "" %>
|
||||||
<%= !caption && row_index == rows.length - 1 && col_index == 0 ? "rounded-bl-md" : "" %>
|
<%= !caption && row_index == rows.length - 1 && col_index == 0 ? "rounded-bl-md" : "" %>
|
||||||
<%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? "rounded-br-md" : "" %>
|
<%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? "rounded-br-md" : "" %>
|
||||||
|
@ -43,5 +42,4 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<h2 class="uppercase px-4 py-2 text-secondary text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
<h2 class="uppercase px-4 py-2 text-secondary text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
||||||
|
|
||||||
<div class="border border-alpha-black-100 rounded-lg bg-container shadow-xs">
|
<div class="border border-alpha-black-100 rounded-lg bg-container shadow-xs">
|
||||||
<%= render partial: "imports/import", collection: @imports.ordered %>
|
<%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -21,9 +21,7 @@
|
||||||
<%= icon("chevron-right") %>
|
<%= icon("chevron-right") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -43,9 +41,7 @@
|
||||||
<%= icon("chevron-right") %>
|
<%= icon("chevron-right") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -65,9 +61,7 @@
|
||||||
<%= icon("chevron-right") %>
|
<%= icon("chevron-right") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -87,9 +81,7 @@
|
||||||
<%= icon("chevron-right") %>
|
<%= icon("chevron-right") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -105,9 +97,7 @@
|
||||||
<%= icon("chevron-right") %>
|
<%= icon("chevron-right") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="pl-14 pr-3">
|
<%= render "shared/ruler" %>
|
||||||
<div class="h-px bg-alpha-black-50"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (account:, url:) %>
|
<%# locals: (account:, url:) %>
|
||||||
|
|
||||||
<%= render "accounts/form", account: account, url: url do |form| %>
|
<%= render "accounts/form", account: account, url: url do |form| %>
|
||||||
<hr class="my-4">
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.fields_for :accountable do |loan_form| %>
|
<%= form.fields_for :accountable do |loan_form| %>
|
||||||
|
|
|
@ -31,13 +31,21 @@
|
||||||
period: @period
|
period: @period
|
||||||
} %>
|
} %>
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag "cashflow_sankey_section" do %>
|
||||||
|
<section class="bg-container py-4 rounded-xl shadow-border-xs">
|
||||||
|
<%= render partial: "pages/dashboard/cashflow_sankey", locals: {
|
||||||
|
sankey_data: @cashflow_sankey_data,
|
||||||
|
period: @cashflow_period
|
||||||
|
} %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<section>
|
<section>
|
||||||
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section>
|
|
||||||
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -58,10 +58,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shadow-border-xs rounded-lg bg-container min-w-fit">
|
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm min-w-fit">
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% classification_group.account_groups.each_with_index do |account_group, idx| %>
|
||||||
<details class="group rounded-lg open:bg-surface font-medium text-sm">
|
<details class="group open:bg-surface
|
||||||
<summary class="focus-visible:outline-none focus-visible:ring-0 cursor-pointer p-4 group-open:bg-surface bg-container rounded-lg flex items-center justify-between">
|
<%= idx == 0 ? "rounded-t-lg" : "" %>
|
||||||
|
<%= idx == classification_group.account_groups.size - 1 ? "rounded-b-lg" : "" %>
|
||||||
|
">
|
||||||
|
<summary class="cursor-pointer p-4 group-open:bg-surface rounded-lg flex items-center justify-between">
|
||||||
<div class="w-40 shrink-0 flex items-center gap-4">
|
<div class="w-40 shrink-0 flex items-center gap-4">
|
||||||
<%= icon("chevron-right", class: "group-open:rotate-90") %>
|
<%= icon("chevron-right", class: "group-open:rotate-90") %>
|
||||||
|
|
||||||
|
@ -124,13 +127,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if idx < account_group.accounts.size - 1 %>
|
<% if idx < account_group.accounts.size - 1 %>
|
||||||
<div class="pl-[84px] pr-40">
|
<%= render "shared/ruler", classes: "ml-21 mr-4" %>
|
||||||
<div class="w-full border-subdued border-b"></div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
<% unless idx == classification_group.account_groups.size - 1 %>
|
||||||
|
<%= render "shared/ruler", classes: "mx-4 group-ruler" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -149,3 +153,10 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# Custom style for hiding ruler when details are open %>
|
||||||
|
<style>
|
||||||
|
details[open] + .group-ruler {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
44
app/views/pages/dashboard/_cashflow_sankey.html.erb
Normal file
44
app/views/pages/dashboard/_cashflow_sankey.html.erb
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<%# locals: (sankey_data:, period:) %>
|
||||||
|
<div id="cashflow-sankey-chart">
|
||||||
|
<div class="flex justify-between items-center gap-4 px-4 mb-4">
|
||||||
|
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
Cashflow
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "cashflow_sankey_section" } do |form| %>
|
||||||
|
<%= form.select :cashflow_period,
|
||||||
|
Period.as_options,
|
||||||
|
{ selected: period.key },
|
||||||
|
data: { "auto-submit-form-target": "auto" },
|
||||||
|
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if sankey_data[:links].present? %>
|
||||||
|
<div class="w-full h-96">
|
||||||
|
<div
|
||||||
|
data-controller="sankey-chart"
|
||||||
|
data-sankey-chart-data-value="<%= sankey_data.to_json %>"
|
||||||
|
data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>"
|
||||||
|
class="w-full h-full"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-[300px] lg:h-[340px] bg-container py-4 flex flex-col items-center justify-center">
|
||||||
|
<div class="space-y-3 text-center flex flex-col items-center">
|
||||||
|
<%= render FilledIconComponent.new(
|
||||||
|
variant: :container,
|
||||||
|
icon: "activity" # cashflow placeholder icon
|
||||||
|
) %>
|
||||||
|
|
||||||
|
<p class="text-sm font-medium text-primary">No cash flow data for this time period</p>
|
||||||
|
<p class="text-secondary text-sm">Add transactions to display cash flow data or expand the time period</p>
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
text: "Add transaction",
|
||||||
|
icon: "plus",
|
||||||
|
href: new_transaction_path,
|
||||||
|
frame: :modal
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -5,7 +5,7 @@
|
||||||
Property::SUBTYPES.map { |k, v| [v[:long], k] },
|
Property::SUBTYPES.map { |k, v| [v[:long], k] },
|
||||||
{ label: true, prompt: t("properties.form.subtype_prompt"), include_blank: t("properties.form.none") } %>
|
{ label: true, prompt: t("properties.form.subtype_prompt"), include_blank: t("properties.form.none") } %>
|
||||||
|
|
||||||
<hr class="my-4">
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.fields_for :accountable do |property_form| %>
|
<%= form.fields_for :accountable do |property_form| %>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<%# locals: (rule:) %>
|
<%# locals: (rule:) %>
|
||||||
<div class="flex justify-between items-center p-4 <%= rule.active? ? "text-primary" : "text-secondary" %>">
|
<div id="<%= dom_id(rule) %>" class="flex justify-between items-center p-4 <%= rule.active? ? "text-primary" : "text-secondary" %>">
|
||||||
|
|
||||||
<div class="text-sm space-y-1.5">
|
<div class="text-sm space-y-1.5">
|
||||||
<% if rule.name.present? %>
|
<% if rule.name.present? %>
|
||||||
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<% if @rules.any? %>
|
<% if @rules.any? %>
|
||||||
<div class="bg-container-inset rounded-xl">
|
<div class="bg-container-inset rounded-xl">
|
||||||
<div class="flex justify-between px-4 py-2 text-xs uppercase">
|
<div class="flex justify-between px-4 py-2 text-xs uppercase">
|
||||||
|
@ -58,13 +58,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<div class="flex flex-col bg-container rounded-xl shadow-border-xs first_child:rounded-t-xl last_child:rounded-b-xl">
|
<div class="flex flex-col bg-container rounded-lg shadow-border-xs">
|
||||||
<% @rules.each_with_index do |rule, idx| %>
|
<%= render partial: "rule", collection: @rules, spacer_template: "shared/ruler" %>
|
||||||
<%= render "rule", rule: rule %>
|
|
||||||
<% unless idx == @rules.size - 1 %>
|
|
||||||
<div class="h-px bg-divider ml-4 mr-6"></div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
|
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm font-medium">
|
<span class="text-sm font-medium">
|
||||||
<%= combobox_security.name.presence || combobox_security.symbol %>
|
<%= combobox_security.name.presence || combobox_security.symbol %>
|
||||||
|
@ -8,4 +9,16 @@
|
||||||
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %>
|
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<% if combobox_security.country_code.present? %>
|
||||||
|
<div class="flex items-center bg-container-inset rounded-sm px-1.5 py-1 gap-1">
|
||||||
|
<%= image_tag("https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg",
|
||||||
|
class: "h-4 rounded-sm", # h-3 (12px) matches text-xs, w-5 for 3:5 aspect ratio
|
||||||
|
alt: "#{combobox_security.country_code.upcase} flag",
|
||||||
|
title: combobox_security.country_code.upcase) %>
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
<%= combobox_security.country_code.upcase %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -48,7 +48,7 @@ nav_sections = [
|
||||||
<section class="space-y-2">
|
<section class="space-y-2">
|
||||||
<div class="flex items-center gap-2 px-3">
|
<div class="flex items-center gap-2 px-3">
|
||||||
<h3 class="uppercase text-secondary font-medium text-xs"><%= section[:header] %></h3>
|
<h3 class="uppercase text-secondary font-medium text-xs"><%= section[:header] %></h3>
|
||||||
<div class="h-px bg-alpha-black-100 w-full"></div>
|
<%= render "shared/ruler", classes: "w-full" %>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<% section[:items].each do |item| %>
|
<% section[:items].each do |item| %>
|
||||||
|
@ -67,7 +67,7 @@ nav_sections = [
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav">
|
<nav class="space-y-4 overflow-y-auto md:hidden" id="mobile-settings-nav" data-controller="preserve-scroll scroll-on-connect">
|
||||||
<ul class="flex space-y-1">
|
<ul class="flex space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
<p class="uppercase text-xs text-secondary font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
<p class="uppercase text-xs text-secondary font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
||||||
</div>
|
</div>
|
||||||
<% @users.each do |user| %>
|
<% @users.each do |user| %>
|
||||||
<div class="flex gap-2 mt-2 items-center bg-container p-4 border border-alpha-black-25 rounded-lg">
|
<div class="flex gap-2 mt-2 items-center bg-container p-4 shadow-border-xs rounded-lg">
|
||||||
<div class="w-9 h-9 shrink-0">
|
<div class="w-9 h-9 shrink-0">
|
||||||
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
|
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
|
||||||
</div>
|
</div>
|
||||||
|
|
2
app/views/shared/_ruler.html.erb
Normal file
2
app/views/shared/_ruler.html.erb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<%# locals: (classes: nil) %>
|
||||||
|
<hr class="border-divider <%= classes || "mx-4" %>">
|
|
@ -2,7 +2,14 @@
|
||||||
<nav class="p-4">
|
<nav class="p-4">
|
||||||
<h1 class="sr-only">Upgrade</h1>
|
<h1 class="sr-only">Upgrade</h1>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end gap-2">
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
text: "Account Settings",
|
||||||
|
icon: "settings",
|
||||||
|
variant: "ghost",
|
||||||
|
href: settings_profile_path,
|
||||||
|
) %>
|
||||||
|
|
||||||
<%= render ButtonComponent.new(
|
<%= render ButtonComponent.new(
|
||||||
text: "Sign out",
|
text: "Sign out",
|
||||||
icon: "log-out",
|
icon: "log-out",
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
<div class="bg-container">
|
|
||||||
<div class="h-px bg-alpha-black-50 ml-4 mr-6"></div>
|
|
||||||
</div>
|
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
<div class="bg-container rounded-xl shadow-border-xs p-4">
|
||||||
<% if @tags.any? %>
|
<% if @tags.any? %>
|
||||||
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
<div class="rounded-xl bg-container-inset space-y-1 p-1">
|
||||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||||
|
@ -33,9 +33,9 @@
|
||||||
<p><%= @tags.count %></p>
|
<p><%= @tags.count %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border border-alpha-black-25 rounded-md bg-container shadow-border-xs">
|
<div class="bg-container rounded-lg shadow-border-xs">
|
||||||
<div class="overflow-hidden rounded-md">
|
<div class="overflow-hidden rounded-lg">
|
||||||
<%= render partial: @tags, spacer_template: "tags/ruler" %>
|
<%= render partial: @tags, spacer_template: "shared/ruler" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (entry:, income_categories:, expense_categories:) %>
|
<%# locals: (entry:, income_categories:, expense_categories:) %>
|
||||||
|
|
||||||
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4", data: { controller: "transaction-form" } do |f| %>
|
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4" do |f| %>
|
||||||
<% if entry.errors.any? %>
|
<% if entry.errors.any? %>
|
||||||
<%= render "shared/form_errors", model: entry %>
|
<%= render "shared/form_errors", model: entry %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
<section>
|
<section>
|
||||||
<%= render "shared/transaction_type_tabs", active_tab: params[:nature] == "inflow" ? "income" : "expense", account_id: params[:account_id] %>
|
<%= render "shared/transaction_type_tabs", active_tab: params[:nature] == "inflow" ? "income" : "expense", account_id: params[:account_id] %>
|
||||||
|
|
||||||
<%= f.hidden_field :nature, value: params[:nature] || "outflow", data: { "transaction-form-target": "natureField" } %>
|
<%= f.hidden_field :nature, value: params[:nature] || "outflow" %>
|
||||||
<%= f.hidden_field :entryable_type, value: "Transaction" %>
|
<%= f.hidden_field :entryable_type, value: "Transaction" %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-alpha-black-100 h-px my-2"></div>
|
<%= render "shared/ruler", classes: "my-2" %>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<dl class="flex items-center gap-2 justify-between">
|
<dl class="flex items-center gap-2 justify-between">
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<%= render partial: "valuations/valuation",
|
<%= render partial: "valuations/valuation",
|
||||||
collection: @entries,
|
collection: @entries,
|
||||||
as: :entry,
|
as: :entry,
|
||||||
spacer_template: "entries/ruler" %>
|
spacer_template: "shared/ruler" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-secondary text-sm p-4"><%= t(".no_valuations") %></p>
|
<p class="text-secondary text-sm p-4"><%= t(".no_valuations") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (account:, url:) %>
|
<%# locals: (account:, url:) %>
|
||||||
|
|
||||||
<%= render "accounts/form", account: account, url: url do |form| %>
|
<%= render "accounts/form", account: account, url: url do |form| %>
|
||||||
<hr class="my-4">
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.fields_for :accountable do |vehicle_form| %>
|
<%= form.fields_for :accountable do |vehicle_form| %>
|
||||||
|
|
|
@ -7,12 +7,12 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||||
pin_all_from "app/components", under: "controllers", to: ""
|
pin_all_from "app/components", under: "controllers", to: ""
|
||||||
pin_all_from "app/javascript/services", under: "services", to: "services"
|
pin_all_from "app/javascript/services", under: "services", to: "services"
|
||||||
pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.0
|
pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.1
|
||||||
pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1
|
pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1
|
||||||
|
|
||||||
# D3 packages
|
# D3 packages
|
||||||
pin "d3" # @7.8.5
|
pin "d3" # @7.9.0
|
||||||
pin "d3-array" # @3.2.4
|
pin "d3-array", to: "shims/d3-array-default.js"
|
||||||
pin "d3-axis" # @3.0.0
|
pin "d3-axis" # @3.0.0
|
||||||
pin "d3-brush" # @3.0.0
|
pin "d3-brush" # @3.0.0
|
||||||
pin "d3-chord" # @3.0.1
|
pin "d3-chord" # @3.0.1
|
||||||
|
@ -26,7 +26,7 @@ pin "d3-ease" # @3.0.1
|
||||||
pin "d3-fetch" # @3.0.1
|
pin "d3-fetch" # @3.0.1
|
||||||
pin "d3-force" # @3.0.0
|
pin "d3-force" # @3.0.0
|
||||||
pin "d3-format" # @3.1.0
|
pin "d3-format" # @3.1.0
|
||||||
pin "d3-geo" # @3.1.0
|
pin "d3-geo" # @3.1.1
|
||||||
pin "d3-hierarchy" # @3.1.2
|
pin "d3-hierarchy" # @3.1.2
|
||||||
pin "d3-interpolate" # @3.0.1
|
pin "d3-interpolate" # @3.0.1
|
||||||
pin "d3-path" # @3.1.0
|
pin "d3-path" # @3.1.0
|
||||||
|
@ -34,9 +34,9 @@ pin "d3-polygon" # @3.0.1
|
||||||
pin "d3-quadtree" # @3.0.1
|
pin "d3-quadtree" # @3.0.1
|
||||||
pin "d3-random" # @3.0.1
|
pin "d3-random" # @3.0.1
|
||||||
pin "d3-scale" # @4.0.2
|
pin "d3-scale" # @4.0.2
|
||||||
pin "d3-scale-chromatic" # @3.0.0
|
pin "d3-scale-chromatic" # @3.1.0
|
||||||
pin "d3-selection" # @3.0.0
|
pin "d3-selection" # @3.0.0
|
||||||
pin "d3-shape" # @3.2.0
|
pin "d3-shape", to: "shims/d3-shape-default.js"
|
||||||
pin "d3-time" # @3.1.0
|
pin "d3-time" # @3.1.0
|
||||||
pin "d3-time-format" # @4.1.0
|
pin "d3-time-format" # @4.1.0
|
||||||
pin "d3-timer" # @3.0.1
|
pin "d3-timer" # @3.0.1
|
||||||
|
@ -45,7 +45,10 @@ pin "d3-zoom" # @3.0.0
|
||||||
pin "delaunator" # @5.0.1
|
pin "delaunator" # @5.0.1
|
||||||
pin "internmap" # @2.0.3
|
pin "internmap" # @2.0.3
|
||||||
pin "robust-predicates" # @3.0.2
|
pin "robust-predicates" # @3.0.2
|
||||||
pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.6.9
|
pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.7.0
|
||||||
pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.6.6
|
pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.7.0
|
||||||
pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.6
|
pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.9
|
||||||
pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.6
|
pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.9
|
||||||
|
pin "d3-sankey" # @0.12.3
|
||||||
|
pin "d3-array-src", to: "d3-array.js"
|
||||||
|
pin "d3-shape-src", to: "d3-shape.js"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import_market_data:
|
import_market_data:
|
||||||
cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time)
|
cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday
|
||||||
class: "ImportMarketDataJob"
|
class: "ImportMarketDataJob"
|
||||||
queue: "scheduled"
|
queue: "scheduled"
|
||||||
description: "Imports market data daily at 5:00 PM EST (1 hour after market close)"
|
description: "Imports market data daily at 5:00 PM EST (1 hour after market close)"
|
||||||
|
@ -12,3 +12,9 @@ clean_syncs:
|
||||||
class: "SyncCleanerJob"
|
class: "SyncCleanerJob"
|
||||||
queue: "scheduled"
|
queue: "scheduled"
|
||||||
description: "Cleans up stale syncs"
|
description: "Cleans up stale syncs"
|
||||||
|
|
||||||
|
run_security_health_checks:
|
||||||
|
cron: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday
|
||||||
|
class: "SecurityHealthCheckJob"
|
||||||
|
queue: "scheduled"
|
||||||
|
description: "Runs security health checks to detect issues with security data"
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
class AddSecurityResolverFields < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :securities, :offline, :boolean, default: false, null: false
|
||||||
|
add_column :securities, :failed_fetch_at, :datetime
|
||||||
|
add_column :securities, :failed_fetch_count, :integer, default: 0, null: false
|
||||||
|
add_column :securities, :last_health_check_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
6
db/schema.rb
generated
6
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do
|
ActiveRecord::Schema[7.2].define(version: 2025_05_21_112347) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -519,6 +519,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do
|
||||||
t.string "exchange_acronym"
|
t.string "exchange_acronym"
|
||||||
t.string "logo_url"
|
t.string "logo_url"
|
||||||
t.string "exchange_operating_mic"
|
t.string "exchange_operating_mic"
|
||||||
|
t.boolean "offline", default: false, null: false
|
||||||
|
t.datetime "failed_fetch_at"
|
||||||
|
t.integer "failed_fetch_count", default: 0, null: false
|
||||||
|
t.datetime "last_health_check_at"
|
||||||
t.index ["country_code"], name: "index_securities_on_country_code"
|
t.index ["country_code"], name: "index_securities_on_country_code"
|
||||||
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
|
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
|
||||||
t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic", unique: true
|
t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic", unique: true
|
||||||
|
|
158
test/models/security/health_checker_test.rb
Normal file
158
test/models/security/health_checker_test.rb
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Security::HealthCheckerTest < ActiveSupport::TestCase
|
||||||
|
include ProviderTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Clean slate
|
||||||
|
Holding.destroy_all
|
||||||
|
Trade.destroy_all
|
||||||
|
Security::Price.delete_all
|
||||||
|
Security.delete_all
|
||||||
|
|
||||||
|
@provider = mock
|
||||||
|
Security.stubs(:provider).returns(@provider)
|
||||||
|
|
||||||
|
# Brand new, no health check has been run yet
|
||||||
|
@new_security = Security.create!(
|
||||||
|
ticker: "NEW",
|
||||||
|
offline: false,
|
||||||
|
last_health_check_at: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
# New security, offline
|
||||||
|
# This will be checked, but unless it gets a price, we keep it offline
|
||||||
|
@new_offline_security = Security.create!(
|
||||||
|
ticker: "NEW_OFFLINE",
|
||||||
|
offline: true,
|
||||||
|
last_health_check_at: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
# Online, recently checked, healthy
|
||||||
|
@healthy_security = Security.create!(
|
||||||
|
ticker: "HEALTHY",
|
||||||
|
offline: false,
|
||||||
|
last_health_check_at: 2.hours.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
# Online, due for a health check
|
||||||
|
@due_for_check_security = Security.create!(
|
||||||
|
ticker: "DUE",
|
||||||
|
offline: false,
|
||||||
|
last_health_check_at: Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago - 1.day
|
||||||
|
)
|
||||||
|
|
||||||
|
# Offline, recently checked (keep offline, don't check)
|
||||||
|
@offline_security = Security.create!(
|
||||||
|
ticker: "OFFLINE",
|
||||||
|
offline: true,
|
||||||
|
last_health_check_at: 20.days.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
# Currently offline, but has had no health check and actually has prices (needs to convert to "online")
|
||||||
|
@offline_never_checked_with_prices = Security.create!(
|
||||||
|
ticker: "OFFLINE_NEVER_CHECKED",
|
||||||
|
offline: true,
|
||||||
|
last_health_check_at: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "any security without a health check runs" do
|
||||||
|
to_check = Security.where(last_health_check_at: nil).or(Security.where(last_health_check_at: ..Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago))
|
||||||
|
Security::HealthChecker.any_instance.expects(:run_check).times(to_check.count)
|
||||||
|
Security::HealthChecker.check_all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "offline security with no health check that fails stays offline" do
|
||||||
|
hc = Security::HealthChecker.new(@new_offline_security)
|
||||||
|
|
||||||
|
@provider.expects(:fetch_security_price)
|
||||||
|
.with(
|
||||||
|
symbol: @new_offline_security.ticker,
|
||||||
|
exchange_operating_mic: @new_offline_security.exchange_operating_mic,
|
||||||
|
date: Date.current
|
||||||
|
)
|
||||||
|
.returns(
|
||||||
|
provider_error_response(StandardError.new("No prices found"))
|
||||||
|
)
|
||||||
|
.once
|
||||||
|
|
||||||
|
hc.run_check
|
||||||
|
|
||||||
|
assert_equal 1, @new_offline_security.failed_fetch_count
|
||||||
|
assert @new_offline_security.offline?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "after enough consecutive health check failures, security goes offline and prices are deleted" do
|
||||||
|
# Create one test price
|
||||||
|
Security::Price.create!(
|
||||||
|
security: @due_for_check_security,
|
||||||
|
date: Date.current,
|
||||||
|
price: 100,
|
||||||
|
currency: "USD"
|
||||||
|
)
|
||||||
|
|
||||||
|
hc = Security::HealthChecker.new(@due_for_check_security)
|
||||||
|
|
||||||
|
@provider.expects(:fetch_security_price)
|
||||||
|
.with(
|
||||||
|
symbol: @due_for_check_security.ticker,
|
||||||
|
exchange_operating_mic: @due_for_check_security.exchange_operating_mic,
|
||||||
|
date: Date.current
|
||||||
|
)
|
||||||
|
.returns(provider_error_response(StandardError.new("No prices found")))
|
||||||
|
.times(Security::HealthChecker::MAX_CONSECUTIVE_FAILURES + 1)
|
||||||
|
|
||||||
|
Security::HealthChecker::MAX_CONSECUTIVE_FAILURES.times do
|
||||||
|
hc.run_check
|
||||||
|
end
|
||||||
|
|
||||||
|
refute @due_for_check_security.offline?
|
||||||
|
assert_equal 1, @due_for_check_security.prices.count
|
||||||
|
|
||||||
|
# We've now exceeded the max consecutive failures, so the security should be marked offline
|
||||||
|
hc.run_check
|
||||||
|
assert @due_for_check_security.offline?
|
||||||
|
assert_equal 0, @due_for_check_security.prices.count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failure incrementor increases for each health check failure" do
|
||||||
|
hc = Security::HealthChecker.new(@due_for_check_security)
|
||||||
|
|
||||||
|
@provider.expects(:fetch_security_price)
|
||||||
|
.with(
|
||||||
|
symbol: @due_for_check_security.ticker,
|
||||||
|
exchange_operating_mic: @due_for_check_security.exchange_operating_mic,
|
||||||
|
date: Date.current
|
||||||
|
)
|
||||||
|
.returns(provider_error_response(StandardError.new("No prices found")))
|
||||||
|
.twice
|
||||||
|
|
||||||
|
hc.run_check
|
||||||
|
assert_equal 1, @due_for_check_security.failed_fetch_count
|
||||||
|
|
||||||
|
hc.run_check
|
||||||
|
assert_equal 2, @due_for_check_security.failed_fetch_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failure incrementor resets to 0 when health check succeeds" do
|
||||||
|
hc = Security::HealthChecker.new(@offline_never_checked_with_prices)
|
||||||
|
|
||||||
|
@provider.expects(:fetch_security_price)
|
||||||
|
.with(
|
||||||
|
symbol: @offline_never_checked_with_prices.ticker,
|
||||||
|
exchange_operating_mic: @offline_never_checked_with_prices.exchange_operating_mic,
|
||||||
|
date: Date.current
|
||||||
|
)
|
||||||
|
.returns(provider_success_response(OpenStruct.new(price: 100, date: Date.current, currency: "USD")))
|
||||||
|
.once
|
||||||
|
|
||||||
|
assert @offline_never_checked_with_prices.offline?
|
||||||
|
|
||||||
|
hc.run_check
|
||||||
|
|
||||||
|
refute @offline_never_checked_with_prices.offline?
|
||||||
|
assert_equal 0, @offline_never_checked_with_prices.failed_fetch_count
|
||||||
|
assert_nil @offline_never_checked_with_prices.failed_fetch_at
|
||||||
|
end
|
||||||
|
end
|
|
@ -43,7 +43,7 @@ class Security::PriceTest < ActiveSupport::TestCase
|
||||||
with_provider_response = provider_error_response(StandardError.new("Test error"))
|
with_provider_response = provider_error_response(StandardError.new("Test error"))
|
||||||
|
|
||||||
@provider.expects(:fetch_security_price)
|
@provider.expects(:fetch_security_price)
|
||||||
.with(security, date: Date.current)
|
.with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, date: Date.current)
|
||||||
.returns(with_provider_response)
|
.returns(with_provider_response)
|
||||||
|
|
||||||
assert_not @security.find_or_fetch_price(date: Date.current)
|
assert_not @security.find_or_fetch_price(date: Date.current)
|
||||||
|
@ -52,7 +52,7 @@ class Security::PriceTest < ActiveSupport::TestCase
|
||||||
private
|
private
|
||||||
def expect_provider_price(security:, price:, date:)
|
def expect_provider_price(security:, price:, date:)
|
||||||
@provider.expects(:fetch_security_price)
|
@provider.expects(:fetch_security_price)
|
||||||
.with(security, date: date)
|
.with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, date: date)
|
||||||
.returns(provider_success_response(price))
|
.returns(provider_success_response(price))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
78
test/models/security/resolver_test.rb
Normal file
78
test/models/security/resolver_test.rb
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Security::ResolverTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@provider = mock
|
||||||
|
Security.stubs(:provider).returns(@provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves DB security" do
|
||||||
|
# Given an existing security in the DB that exactly matches the lookup params
|
||||||
|
db_security = Security.create!(ticker: "TSLA", exchange_operating_mic: "XNAS", country_code: "US")
|
||||||
|
|
||||||
|
# The resolver should return the DB record and never hit the provider
|
||||||
|
Security.expects(:search_provider).never
|
||||||
|
|
||||||
|
resolved = Security::Resolver.new("TSLA", exchange_operating_mic: "XNAS", country_code: "US").resolve
|
||||||
|
|
||||||
|
assert_equal db_security, resolved
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves exact provider match" do
|
||||||
|
# Provider returns multiple results, one of which exactly matches symbol + exchange (and country)
|
||||||
|
exact_match = Security.new(ticker: "NVDA", exchange_operating_mic: "XNAS", country_code: "US")
|
||||||
|
near_miss = Security.new(ticker: "NVDA", exchange_operating_mic: "XNYS", country_code: "US")
|
||||||
|
|
||||||
|
Security.expects(:search_provider)
|
||||||
|
.with("NVDA", exchange_operating_mic: "XNAS", country_code: "US")
|
||||||
|
.returns([ near_miss, exact_match ])
|
||||||
|
|
||||||
|
assert_difference "Security.count", 1 do
|
||||||
|
resolved = Security::Resolver.new("NVDA", exchange_operating_mic: "XNAS", country_code: "US").resolve
|
||||||
|
|
||||||
|
assert resolved.persisted?
|
||||||
|
assert_equal "NVDA", resolved.ticker
|
||||||
|
assert_equal "XNAS", resolved.exchange_operating_mic
|
||||||
|
assert_equal "US", resolved.country_code
|
||||||
|
refute resolved.offline, "Exact provider matches should not be marked offline"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves close provider match" do
|
||||||
|
# No exact match – resolver should choose the most relevant close match based on exchange + country ranking
|
||||||
|
preferred = Security.new(ticker: "TEST1", exchange_operating_mic: "XNAS", country_code: "US")
|
||||||
|
other = Security.new(ticker: "TEST2", exchange_operating_mic: "XNYS", country_code: "GB")
|
||||||
|
|
||||||
|
# Return in reverse-priority order to prove the sorter works
|
||||||
|
Security.expects(:search_provider)
|
||||||
|
.with("TEST", exchange_operating_mic: "XNAS")
|
||||||
|
.returns([ other, preferred ])
|
||||||
|
|
||||||
|
assert_difference "Security.count", 1 do
|
||||||
|
resolved = Security::Resolver.new("TEST", exchange_operating_mic: "XNAS").resolve
|
||||||
|
|
||||||
|
assert resolved.persisted?
|
||||||
|
assert_equal "TEST1", resolved.ticker
|
||||||
|
assert_equal "XNAS", resolved.exchange_operating_mic
|
||||||
|
assert_equal "US", resolved.country_code
|
||||||
|
refute resolved.offline, "Provider matches should not be marked offline"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves offline security" do
|
||||||
|
Security.expects(:search_provider).returns([])
|
||||||
|
|
||||||
|
assert_difference "Security.count", 1 do
|
||||||
|
resolved = Security::Resolver.new("FOO").resolve
|
||||||
|
|
||||||
|
assert resolved.persisted?, "Offline security should be saved"
|
||||||
|
assert_equal "FOO", resolved.ticker
|
||||||
|
assert resolved.offline, "Offline securities should be flagged offline"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when symbol blank" do
|
||||||
|
assert_raises(ArgumentError) { Security::Resolver.new(nil).resolve }
|
||||||
|
assert_raises(ArgumentError) { Security::Resolver.new("").resolve }
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,23 +11,24 @@ class TradeImportTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "imports trades and accounts" do
|
test "imports trades and accounts" do
|
||||||
# Create an existing AAPL security with no exchange_operating_mic
|
aapl_resolver = mock
|
||||||
aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: nil)
|
googl_resolver = mock
|
||||||
|
|
||||||
# We should only hit the provider for GOOGL since AAPL already exists
|
Security::Resolver.expects(:new)
|
||||||
Security.expects(:search_provider).with(
|
.with("AAPL", exchange_operating_mic: nil)
|
||||||
"GOOGL",
|
.returns(aapl_resolver)
|
||||||
exchange_operating_mic: "XNAS"
|
.once
|
||||||
).returns([
|
|
||||||
Security.new(
|
Security::Resolver.expects(:new)
|
||||||
ticker: "GOOGL",
|
.with("GOOGL", exchange_operating_mic: "XNAS")
|
||||||
name: "Google Inc.",
|
.returns(googl_resolver)
|
||||||
country_code: "US",
|
.once
|
||||||
exchange_mic: "XNGS",
|
|
||||||
exchange_operating_mic: "XNAS",
|
aapl = securities(:aapl)
|
||||||
exchange_acronym: "NGS"
|
googl = Security.create!(ticker: "GOOGL", exchange_operating_mic: "XNAS")
|
||||||
)
|
|
||||||
]).once
|
aapl_resolver.stubs(:resolve).returns(aapl)
|
||||||
|
googl_resolver.stubs(:resolve).returns(googl)
|
||||||
|
|
||||||
import = <<~CSV
|
import = <<~CSV
|
||||||
date,ticker,qty,price,currency,account,name,exchange_operating_mic
|
date,ticker,qty,price,currency,account,name,exchange_operating_mic
|
||||||
|
@ -55,19 +56,10 @@ class TradeImportTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_difference -> { Entry.count } => 2,
|
assert_difference -> { Entry.count } => 2,
|
||||||
-> { Trade.count } => 2,
|
-> { Trade.count } => 2,
|
||||||
-> { Security.count } => 1,
|
|
||||||
-> { Account.count } => 1 do
|
-> { Account.count } => 1 do
|
||||||
@import.publish
|
@import.publish
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "complete", @import.status
|
assert_equal "complete", @import.status
|
||||||
|
|
||||||
# Verify the securities were created/updated correctly
|
|
||||||
aapl.reload
|
|
||||||
assert_nil aapl.exchange_operating_mic
|
|
||||||
|
|
||||||
googl = Security.find_by(ticker: "GOOGL")
|
|
||||||
assert_equal "XNAS", googl.exchange_operating_mic
|
|
||||||
assert_equal "XNGS", googl.exchange_mic
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
function getNodeName(e){return isNode(e)?(e.nodeName||"").toLowerCase():"#document"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return e instanceof Node||e instanceof getWindow(e).Node}function isElement(e){return e instanceof Element||e instanceof getWindow(e).Element}function isHTMLElement(e){return e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement}function isShadowRoot(e){return typeof ShadowRoot!=="undefined"&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!["inline","contents"].includes(r)}function isTableElement(e){return["table","td","th"].includes(getNodeName(e))}function isTopLayer(e){return[":popover-open",":modal"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return n.transform!=="none"||n.perspective!=="none"||!!n.containerType&&n.containerType!=="normal"||!t&&!!n.backdropFilter&&n.backdropFilter!=="none"||!t&&!!n.filter&&n.filter!=="none"||["transform","perspective","filter"].some((e=>(n.willChange||"").includes(e)))||["paint","layout","strict","content"].some((e=>(n.contain||"").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS==="undefined"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function isLastTraversableNode(e){return["html","body","#document"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)==="html")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit};
|
// @floating-ui/utils/dom@0.2.9 downloaded from https://ga.jspm.io/npm:@floating-ui/utils@0.2.9/dist/floating-ui.utils.dom.mjs
|
||||||
|
|
||||||
|
function hasWindow(){return typeof window!=="undefined"}function getNodeName(e){return isNode(e)?(e.nodeName||"").toLowerCase():"#document"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return!!hasWindow()&&(e instanceof Node||e instanceof getWindow(e).Node)}function isElement(e){return!!hasWindow()&&(e instanceof Element||e instanceof getWindow(e).Element)}function isHTMLElement(e){return!!hasWindow()&&(e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement)}function isShadowRoot(e){return!(!hasWindow()||typeof ShadowRoot==="undefined")&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!["inline","contents"].includes(r)}function isTableElement(e){return["table","td","th"].includes(getNodeName(e))}function isTopLayer(e){return[":popover-open",":modal"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return["transform","translate","scale","rotate","perspective"].some((e=>!!n[e]&&n[e]!=="none"))||!!n.containerType&&n.containerType!=="normal"||!t&&!!n.backdropFilter&&n.backdropFilter!=="none"||!t&&!!n.filter&&n.filter!=="none"||["transform","translate","scale","rotate","perspective","filter"].some((e=>(n.willChange||"").includes(e)))||["paint","layout","strict","content"].some((e=>(n.contain||"").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS==="undefined"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function isLastTraversableNode(e){return["html","body","#document"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)==="html")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-array.js
vendored
2
vendor/javascript/d3-array.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-axis.js
vendored
2
vendor/javascript/d3-axis.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-axis@3.0.0 downloaded from https://ga.jspm.io/npm:d3-axis@3.0.0/src/index.js
|
||||||
|
|
||||||
function identity(t){return t}var t=1,n=2,r=3,i=4,e=1e-6;function translateX(t){return"translate("+t+",0)"}function translateY(t){return"translate(0,"+t+")"}function number(t){return n=>+t(n)}function center(t,n){n=Math.max(0,t.bandwidth()-2*n)/2;t.round()&&(n=Math.round(n));return r=>+t(r)+n}function entering(){return!this.__axis}function axis(a,s){var o=[],u=null,c=null,l=6,x=6,f=3,d="undefined"!==typeof window&&window.devicePixelRatio>1?0:.5,m=a===t||a===i?-1:1,h=a===i||a===n?"x":"y",g=a===t||a===r?translateX:translateY;function axis(p){var k=null==u?s.ticks?s.ticks.apply(s,o):s.domain():u,y=null==c?s.tickFormat?s.tickFormat.apply(s,o):identity:c,A=Math.max(l,0)+f,M=s.range(),v=+M[0]+d,w=+M[M.length-1]+d,_=(s.bandwidth?center:number)(s.copy(),d),b=p.selection?p.selection():p,F=b.selectAll(".domain").data([null]),V=b.selectAll(".tick").data(k,s).order(),z=V.exit(),H=V.enter().append("g").attr("class","tick"),C=V.select("line"),R=V.select("text");F=F.merge(F.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor"));V=V.merge(H);C=C.merge(H.append("line").attr("stroke","currentColor").attr(h+"2",m*l));R=R.merge(H.append("text").attr("fill","currentColor").attr(h,m*A).attr("dy",a===t?"0em":a===r?"0.71em":"0.32em"));if(p!==b){F=F.transition(p);V=V.transition(p);C=C.transition(p);R=R.transition(p);z=z.transition(p).attr("opacity",e).attr("transform",(function(t){return isFinite(t=_(t))?g(t+d):this.getAttribute("transform")}));H.attr("opacity",e).attr("transform",(function(t){var n=this.parentNode.__axis;return g((n&&isFinite(n=n(t))?n:_(t))+d)}))}z.remove();F.attr("d",a===i||a===n?x?"M"+m*x+","+v+"H"+d+"V"+w+"H"+m*x:"M"+d+","+v+"V"+w:x?"M"+v+","+m*x+"V"+d+"H"+w+"V"+m*x:"M"+v+","+d+"H"+w);V.attr("opacity",1).attr("transform",(function(t){return g(_(t)+d)}));C.attr(h+"2",m*l);R.attr(h,m*A).text(y);b.filter(entering).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",a===n?"start":a===i?"end":"middle");b.each((function(){this.__axis=_}))}axis.scale=function(t){return arguments.length?(s=t,axis):s};axis.ticks=function(){return o=Array.from(arguments),axis};axis.tickArguments=function(t){return arguments.length?(o=null==t?[]:Array.from(t),axis):o.slice()};axis.tickValues=function(t){return arguments.length?(u=null==t?null:Array.from(t),axis):u&&u.slice()};axis.tickFormat=function(t){return arguments.length?(c=t,axis):c};axis.tickSize=function(t){return arguments.length?(l=x=+t,axis):l};axis.tickSizeInner=function(t){return arguments.length?(l=+t,axis):l};axis.tickSizeOuter=function(t){return arguments.length?(x=+t,axis):x};axis.tickPadding=function(t){return arguments.length?(f=+t,axis):f};axis.offset=function(t){return arguments.length?(d=+t,axis):d};return axis}function axisTop(n){return axis(t,n)}function axisRight(t){return axis(n,t)}function axisBottom(t){return axis(r,t)}function axisLeft(t){return axis(i,t)}export{axisBottom,axisLeft,axisRight,axisTop};
|
function identity(t){return t}var t=1,n=2,r=3,i=4,e=1e-6;function translateX(t){return"translate("+t+",0)"}function translateY(t){return"translate(0,"+t+")"}function number(t){return n=>+t(n)}function center(t,n){n=Math.max(0,t.bandwidth()-2*n)/2;t.round()&&(n=Math.round(n));return r=>+t(r)+n}function entering(){return!this.__axis}function axis(a,s){var o=[],u=null,c=null,l=6,x=6,f=3,d="undefined"!==typeof window&&window.devicePixelRatio>1?0:.5,m=a===t||a===i?-1:1,h=a===i||a===n?"x":"y",g=a===t||a===r?translateX:translateY;function axis(p){var k=null==u?s.ticks?s.ticks.apply(s,o):s.domain():u,y=null==c?s.tickFormat?s.tickFormat.apply(s,o):identity:c,A=Math.max(l,0)+f,M=s.range(),v=+M[0]+d,w=+M[M.length-1]+d,_=(s.bandwidth?center:number)(s.copy(),d),b=p.selection?p.selection():p,F=b.selectAll(".domain").data([null]),V=b.selectAll(".tick").data(k,s).order(),z=V.exit(),H=V.enter().append("g").attr("class","tick"),C=V.select("line"),R=V.select("text");F=F.merge(F.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor"));V=V.merge(H);C=C.merge(H.append("line").attr("stroke","currentColor").attr(h+"2",m*l));R=R.merge(H.append("text").attr("fill","currentColor").attr(h,m*A).attr("dy",a===t?"0em":a===r?"0.71em":"0.32em"));if(p!==b){F=F.transition(p);V=V.transition(p);C=C.transition(p);R=R.transition(p);z=z.transition(p).attr("opacity",e).attr("transform",(function(t){return isFinite(t=_(t))?g(t+d):this.getAttribute("transform")}));H.attr("opacity",e).attr("transform",(function(t){var n=this.parentNode.__axis;return g((n&&isFinite(n=n(t))?n:_(t))+d)}))}z.remove();F.attr("d",a===i||a===n?x?"M"+m*x+","+v+"H"+d+"V"+w+"H"+m*x:"M"+d+","+v+"V"+w:x?"M"+v+","+m*x+"V"+d+"H"+w+"V"+m*x:"M"+v+","+d+"H"+w);V.attr("opacity",1).attr("transform",(function(t){return g(_(t)+d)}));C.attr(h+"2",m*l);R.attr(h,m*A).text(y);b.filter(entering).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",a===n?"start":a===i?"end":"middle");b.each((function(){this.__axis=_}))}axis.scale=function(t){return arguments.length?(s=t,axis):s};axis.ticks=function(){return o=Array.from(arguments),axis};axis.tickArguments=function(t){return arguments.length?(o=null==t?[]:Array.from(t),axis):o.slice()};axis.tickValues=function(t){return arguments.length?(u=null==t?null:Array.from(t),axis):u&&u.slice()};axis.tickFormat=function(t){return arguments.length?(c=t,axis):c};axis.tickSize=function(t){return arguments.length?(l=x=+t,axis):l};axis.tickSizeInner=function(t){return arguments.length?(l=+t,axis):l};axis.tickSizeOuter=function(t){return arguments.length?(x=+t,axis):x};axis.tickPadding=function(t){return arguments.length?(f=+t,axis):f};axis.offset=function(t){return arguments.length?(d=+t,axis):d};return axis}function axisTop(n){return axis(t,n)}function axisRight(t){return axis(n,t)}function axisBottom(t){return axis(r,t)}function axisLeft(t){return axis(i,t)}export{axisBottom,axisLeft,axisRight,axisTop};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-brush.js
vendored
2
vendor/javascript/d3-brush.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-chord.js
vendored
2
vendor/javascript/d3-chord.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-chord@3.0.1 downloaded from https://ga.jspm.io/npm:d3-chord@3.0.1/src/index.js
|
||||||
|
|
||||||
import{path as n}from"d3-path";var r=Math.abs;var t=Math.cos;var e=Math.sin;var o=Math.PI;var u=o/2;var a=2*o;var l=Math.max;var i=1e-12;function range(n,r){return Array.from({length:r-n},((r,t)=>n+t))}function compareValue(n){return function(r,t){return n(r.source.value+r.target.value,t.source.value+t.target.value)}}function chord(){return chord$1(false,false)}function chordTranspose(){return chord$1(false,true)}function chordDirected(){return chord$1(true,false)}function chord$1(n,r){var t=0,e=null,o=null,u=null;function chord(i){var c,s=i.length,f=new Array(s),d=range(0,s),g=new Array(s*s),b=new Array(s),h=0;i=Float64Array.from({length:s*s},r?(n,r)=>i[r%s][r/s|0]:(n,r)=>i[r/s|0][r%s]);for(let r=0;r<s;++r){let t=0;for(let e=0;e<s;++e)t+=i[r*s+e]+n*i[e*s+r];h+=f[r]=t}h=l(0,a-t*s)/h;c=h?t:a/s;{let r=0;e&&d.sort(((n,r)=>e(f[n],f[r])));for(const t of d){const e=r;if(n){const n=range(1+~s,s).filter((n=>n<0?i[~n*s+t]:i[t*s+n]));o&&n.sort(((n,r)=>o(n<0?-i[~n*s+t]:i[t*s+n],r<0?-i[~r*s+t]:i[t*s+r])));for(const e of n)if(e<0){const n=g[~e*s+t]||(g[~e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[~e*s+t]*h,value:i[~e*s+t]}}else{const n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}else{const n=range(0,s).filter((n=>i[t*s+n]||i[n*s+t]));o&&n.sort(((n,r)=>o(i[t*s+n],i[t*s+r])));for(const e of n){let n;if(t<e){n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}else{n=g[e*s+t]||(g[e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]};t===e&&(n.source=n.target)}if(n.source&&n.target&&n.source.value<n.target.value){const r=n.source;n.source=n.target;n.target=r}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}r+=c}}g=Object.values(g);g.groups=b;return u?g.sort(u):g}chord.padAngle=function(n){return arguments.length?(t=l(0,n),chord):t};chord.sortGroups=function(n){return arguments.length?(e=n,chord):e};chord.sortSubgroups=function(n){return arguments.length?(o=n,chord):o};chord.sortChords=function(n){return arguments.length?(null==n?u=null:(u=compareValue(n))._=n,chord):u&&u._};return chord}var c=Array.prototype.slice;function constant(n){return function(){return n}}function defaultSource(n){return n.source}function defaultTarget(n){return n.target}function defaultRadius(n){return n.radius}function defaultStartAngle(n){return n.startAngle}function defaultEndAngle(n){return n.endAngle}function defaultPadAngle(){return 0}function defaultArrowheadRadius(){return 10}function ribbon(o){var a=defaultSource,l=defaultTarget,s=defaultRadius,f=defaultRadius,d=defaultStartAngle,g=defaultEndAngle,b=defaultPadAngle,h=null;function ribbon(){var p,A=a.apply(this,arguments),v=l.apply(this,arguments),y=b.apply(this,arguments)/2,T=c.call(arguments),x=+s.apply(this,(T[0]=A,T)),m=d.apply(this,T)-u,R=g.apply(this,T)-u,w=+f.apply(this,(T[0]=v,T)),$=d.apply(this,T)-u,M=g.apply(this,T)-u;h||(h=p=n());if(y>i){r(R-m)>2*y+i?R>m?(m+=y,R-=y):(m-=y,R+=y):m=R=(m+R)/2;r(M-$)>2*y+i?M>$?($+=y,M-=y):($-=y,M+=y):$=M=($+M)/2}h.moveTo(x*t(m),x*e(m));h.arc(0,0,x,m,R);if(m!==$||R!==M)if(o){var S=+o.apply(this,arguments),C=w-S,P=($+M)/2;h.quadraticCurveTo(0,0,C*t($),C*e($));h.lineTo(w*t(P),w*e(P));h.lineTo(C*t(M),C*e(M))}else{h.quadraticCurveTo(0,0,w*t($),w*e($));h.arc(0,0,w,$,M)}h.quadraticCurveTo(0,0,x*t(m),x*e(m));h.closePath();if(p)return h=null,p+""||null}o&&(ribbon.headRadius=function(n){return arguments.length?(o="function"===typeof n?n:constant(+n),ribbon):o});ribbon.radius=function(n){return arguments.length?(s=f="function"===typeof n?n:constant(+n),ribbon):s};ribbon.sourceRadius=function(n){return arguments.length?(s="function"===typeof n?n:constant(+n),ribbon):s};ribbon.targetRadius=function(n){return arguments.length?(f="function"===typeof n?n:constant(+n),ribbon):f};ribbon.startAngle=function(n){return arguments.length?(d="function"===typeof n?n:constant(+n),ribbon):d};ribbon.endAngle=function(n){return arguments.length?(g="function"===typeof n?n:constant(+n),ribbon):g};ribbon.padAngle=function(n){return arguments.length?(b="function"===typeof n?n:constant(+n),ribbon):b};ribbon.source=function(n){return arguments.length?(a=n,ribbon):a};ribbon.target=function(n){return arguments.length?(l=n,ribbon):l};ribbon.context=function(n){return arguments.length?(h=null==n?null:n,ribbon):h};return ribbon}function ribbon$1(){return ribbon()}function ribbonArrow(){return ribbon(defaultArrowheadRadius)}export{chord,chordDirected,chordTranspose,ribbon$1 as ribbon,ribbonArrow};
|
import{path as n}from"d3-path";var r=Math.abs;var t=Math.cos;var e=Math.sin;var o=Math.PI;var u=o/2;var a=2*o;var l=Math.max;var i=1e-12;function range(n,r){return Array.from({length:r-n},((r,t)=>n+t))}function compareValue(n){return function(r,t){return n(r.source.value+r.target.value,t.source.value+t.target.value)}}function chord(){return chord$1(false,false)}function chordTranspose(){return chord$1(false,true)}function chordDirected(){return chord$1(true,false)}function chord$1(n,r){var t=0,e=null,o=null,u=null;function chord(i){var c,s=i.length,f=new Array(s),d=range(0,s),g=new Array(s*s),b=new Array(s),h=0;i=Float64Array.from({length:s*s},r?(n,r)=>i[r%s][r/s|0]:(n,r)=>i[r/s|0][r%s]);for(let r=0;r<s;++r){let t=0;for(let e=0;e<s;++e)t+=i[r*s+e]+n*i[e*s+r];h+=f[r]=t}h=l(0,a-t*s)/h;c=h?t:a/s;{let r=0;e&&d.sort(((n,r)=>e(f[n],f[r])));for(const t of d){const e=r;if(n){const n=range(1+~s,s).filter((n=>n<0?i[~n*s+t]:i[t*s+n]));o&&n.sort(((n,r)=>o(n<0?-i[~n*s+t]:i[t*s+n],r<0?-i[~r*s+t]:i[t*s+r])));for(const e of n)if(e<0){const n=g[~e*s+t]||(g[~e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[~e*s+t]*h,value:i[~e*s+t]}}else{const n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}else{const n=range(0,s).filter((n=>i[t*s+n]||i[n*s+t]));o&&n.sort(((n,r)=>o(i[t*s+n],i[t*s+r])));for(const e of n){let n;if(t<e){n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}else{n=g[e*s+t]||(g[e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]};t===e&&(n.source=n.target)}if(n.source&&n.target&&n.source.value<n.target.value){const r=n.source;n.source=n.target;n.target=r}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}r+=c}}g=Object.values(g);g.groups=b;return u?g.sort(u):g}chord.padAngle=function(n){return arguments.length?(t=l(0,n),chord):t};chord.sortGroups=function(n){return arguments.length?(e=n,chord):e};chord.sortSubgroups=function(n){return arguments.length?(o=n,chord):o};chord.sortChords=function(n){return arguments.length?(null==n?u=null:(u=compareValue(n))._=n,chord):u&&u._};return chord}var c=Array.prototype.slice;function constant(n){return function(){return n}}function defaultSource(n){return n.source}function defaultTarget(n){return n.target}function defaultRadius(n){return n.radius}function defaultStartAngle(n){return n.startAngle}function defaultEndAngle(n){return n.endAngle}function defaultPadAngle(){return 0}function defaultArrowheadRadius(){return 10}function ribbon(o){var a=defaultSource,l=defaultTarget,s=defaultRadius,f=defaultRadius,d=defaultStartAngle,g=defaultEndAngle,b=defaultPadAngle,h=null;function ribbon(){var p,A=a.apply(this,arguments),v=l.apply(this,arguments),y=b.apply(this,arguments)/2,T=c.call(arguments),x=+s.apply(this,(T[0]=A,T)),m=d.apply(this,T)-u,R=g.apply(this,T)-u,w=+f.apply(this,(T[0]=v,T)),$=d.apply(this,T)-u,M=g.apply(this,T)-u;h||(h=p=n());if(y>i){r(R-m)>2*y+i?R>m?(m+=y,R-=y):(m-=y,R+=y):m=R=(m+R)/2;r(M-$)>2*y+i?M>$?($+=y,M-=y):($-=y,M+=y):$=M=($+M)/2}h.moveTo(x*t(m),x*e(m));h.arc(0,0,x,m,R);if(m!==$||R!==M)if(o){var S=+o.apply(this,arguments),C=w-S,P=($+M)/2;h.quadraticCurveTo(0,0,C*t($),C*e($));h.lineTo(w*t(P),w*e(P));h.lineTo(C*t(M),C*e(M))}else{h.quadraticCurveTo(0,0,w*t($),w*e($));h.arc(0,0,w,$,M)}h.quadraticCurveTo(0,0,x*t(m),x*e(m));h.closePath();if(p)return h=null,p+""||null}o&&(ribbon.headRadius=function(n){return arguments.length?(o="function"===typeof n?n:constant(+n),ribbon):o});ribbon.radius=function(n){return arguments.length?(s=f="function"===typeof n?n:constant(+n),ribbon):s};ribbon.sourceRadius=function(n){return arguments.length?(s="function"===typeof n?n:constant(+n),ribbon):s};ribbon.targetRadius=function(n){return arguments.length?(f="function"===typeof n?n:constant(+n),ribbon):f};ribbon.startAngle=function(n){return arguments.length?(d="function"===typeof n?n:constant(+n),ribbon):d};ribbon.endAngle=function(n){return arguments.length?(g="function"===typeof n?n:constant(+n),ribbon):g};ribbon.padAngle=function(n){return arguments.length?(b="function"===typeof n?n:constant(+n),ribbon):b};ribbon.source=function(n){return arguments.length?(a=n,ribbon):a};ribbon.target=function(n){return arguments.length?(l=n,ribbon):l};ribbon.context=function(n){return arguments.length?(h=null==n?null:n,ribbon):h};return ribbon}function ribbon$1(){return ribbon()}function ribbonArrow(){return ribbon(defaultArrowheadRadius)}export{chord,chordDirected,chordTranspose,ribbon$1 as ribbon,ribbonArrow};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-color.js
vendored
2
vendor/javascript/d3-color.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-contour.js
vendored
2
vendor/javascript/d3-contour.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-delaunay.js
vendored
2
vendor/javascript/d3-delaunay.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-dispatch.js
vendored
2
vendor/javascript/d3-dispatch.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-dispatch@3.0.1 downloaded from https://ga.jspm.io/npm:d3-dispatch@3.0.1/src/index.js
|
||||||
|
|
||||||
var n={value:()=>{}};function dispatch(){for(var n,t=0,e=arguments.length,r={};t<e;++t){if(!(n=arguments[t]+"")||n in r||/[\s.]/.test(n))throw new Error("illegal type: "+n);r[n]=[]}return new Dispatch(r)}function Dispatch(n){this._=n}function parseTypenames(n,t){return n.trim().split(/^|\s+/).map((function(n){var e="",r=n.indexOf(".");r>=0&&(e=n.slice(r+1),n=n.slice(0,r));if(n&&!t.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:e}}))}Dispatch.prototype=dispatch.prototype={constructor:Dispatch,on:function(n,t){var e,r=this._,i=parseTypenames(n+"",r),a=-1,o=i.length;if(!(arguments.length<2)){if(null!=t&&"function"!==typeof t)throw new Error("invalid callback: "+t);while(++a<o)if(e=(n=i[a]).type)r[e]=set(r[e],n.name,t);else if(null==t)for(e in r)r[e]=set(r[e],n.name,null);return this}while(++a<o)if((e=(n=i[a]).type)&&(e=get(r[e],n.name)))return e},copy:function(){var n={},t=this._;for(var e in t)n[e]=t[e].slice();return new Dispatch(n)},call:function(n,t){if((e=arguments.length-2)>0)for(var e,r,i=new Array(e),a=0;a<e;++a)i[a]=arguments[a+2];if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(r=this._[n],a=0,e=r.length;a<e;++a)r[a].value.apply(t,i)},apply:function(n,t,e){if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(var r=this._[n],i=0,a=r.length;i<a;++i)r[i].value.apply(t,e)}};function get(n,t){for(var e,r=0,i=n.length;r<i;++r)if((e=n[r]).name===t)return e.value}function set(t,e,r){for(var i=0,a=t.length;i<a;++i)if(t[i].name===e){t[i]=n,t=t.slice(0,i).concat(t.slice(i+1));break}null!=r&&t.push({name:e,value:r});return t}export{dispatch};
|
var n={value:()=>{}};function dispatch(){for(var n,t=0,e=arguments.length,r={};t<e;++t){if(!(n=arguments[t]+"")||n in r||/[\s.]/.test(n))throw new Error("illegal type: "+n);r[n]=[]}return new Dispatch(r)}function Dispatch(n){this._=n}function parseTypenames(n,t){return n.trim().split(/^|\s+/).map((function(n){var e="",r=n.indexOf(".");r>=0&&(e=n.slice(r+1),n=n.slice(0,r));if(n&&!t.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:e}}))}Dispatch.prototype=dispatch.prototype={constructor:Dispatch,on:function(n,t){var e,r=this._,i=parseTypenames(n+"",r),a=-1,o=i.length;if(!(arguments.length<2)){if(null!=t&&"function"!==typeof t)throw new Error("invalid callback: "+t);while(++a<o)if(e=(n=i[a]).type)r[e]=set(r[e],n.name,t);else if(null==t)for(e in r)r[e]=set(r[e],n.name,null);return this}while(++a<o)if((e=(n=i[a]).type)&&(e=get(r[e],n.name)))return e},copy:function(){var n={},t=this._;for(var e in t)n[e]=t[e].slice();return new Dispatch(n)},call:function(n,t){if((e=arguments.length-2)>0)for(var e,r,i=new Array(e),a=0;a<e;++a)i[a]=arguments[a+2];if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(r=this._[n],a=0,e=r.length;a<e;++a)r[a].value.apply(t,i)},apply:function(n,t,e){if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(var r=this._[n],i=0,a=r.length;i<a;++i)r[i].value.apply(t,e)}};function get(n,t){for(var e,r=0,i=n.length;r<i;++r)if((e=n[r]).name===t)return e.value}function set(t,e,r){for(var i=0,a=t.length;i<a;++i)if(t[i].name===e){t[i]=n,t=t.slice(0,i).concat(t.slice(i+1));break}null!=r&&t.push({name:e,value:r});return t}export{dispatch};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-drag.js
vendored
2
vendor/javascript/d3-drag.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-drag@3.0.0 downloaded from https://ga.jspm.io/npm:d3-drag@3.0.0/src/index.js
|
||||||
|
|
||||||
import{dispatch as e}from"d3-dispatch";import{select as t,pointer as n}from"d3-selection";const r={passive:false};const a={capture:true,passive:false};function nopropagation(e){e.stopImmediatePropagation()}function noevent(e){e.preventDefault();e.stopImmediatePropagation()}function nodrag(e){var n=e.document.documentElement,r=t(e).on("dragstart.drag",noevent,a);if("onselectstart"in n)r.on("selectstart.drag",noevent,a);else{n.__noselect=n.style.MozUserSelect;n.style.MozUserSelect="none"}}function yesdrag(e,n){var r=e.document.documentElement,o=t(e).on("dragstart.drag",null);if(n){o.on("click.drag",noevent,a);setTimeout((function(){o.on("click.drag",null)}),0)}if("onselectstart"in r)o.on("selectstart.drag",null);else{r.style.MozUserSelect=r.__noselect;delete r.__noselect}}var constant=e=>()=>e;function DragEvent(e,{sourceEvent:t,subject:n,target:r,identifier:a,active:o,x:u,y:i,dx:c,dy:l,dispatch:d}){Object.defineProperties(this,{type:{value:e,enumerable:true,configurable:true},sourceEvent:{value:t,enumerable:true,configurable:true},subject:{value:n,enumerable:true,configurable:true},target:{value:r,enumerable:true,configurable:true},identifier:{value:a,enumerable:true,configurable:true},active:{value:o,enumerable:true,configurable:true},x:{value:u,enumerable:true,configurable:true},y:{value:i,enumerable:true,configurable:true},dx:{value:c,enumerable:true,configurable:true},dy:{value:l,enumerable:true,configurable:true},_:{value:d}})}DragEvent.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function defaultFilter(e){return!e.ctrlKey&&!e.button}function defaultContainer(){return this.parentNode}function defaultSubject(e,t){return null==t?{x:e.x,y:e.y}:t}function defaultTouchable(){return navigator.maxTouchPoints||"ontouchstart"in this}function drag(){var o,u,i,c,l=defaultFilter,d=defaultContainer,s=defaultSubject,f=defaultTouchable,g={},v=e("start","drag","end"),h=0,m=0;function drag(e){e.on("mousedown.drag",mousedowned).filter(f).on("touchstart.drag",touchstarted).on("touchmove.drag",touchmoved,r).on("touchend.drag touchcancel.drag",touchended).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function mousedowned(e,n){if(!c&&l.call(this,e,n)){var r=beforestart(this,d.call(this,e,n),e,n,"mouse");if(r){t(e.view).on("mousemove.drag",mousemoved,a).on("mouseup.drag",mouseupped,a);nodrag(e.view);nopropagation(e);i=false;o=e.clientX;u=e.clientY;r("start",e)}}}function mousemoved(e){noevent(e);if(!i){var t=e.clientX-o,n=e.clientY-u;i=t*t+n*n>m}g.mouse("drag",e)}function mouseupped(e){t(e.view).on("mousemove.drag mouseup.drag",null);yesdrag(e.view,i);noevent(e);g.mouse("end",e)}function touchstarted(e,t){if(l.call(this,e,t)){var n,r,a=e.changedTouches,o=d.call(this,e,t),u=a.length;for(n=0;n<u;++n)if(r=beforestart(this,o,e,t,a[n].identifier,a[n])){nopropagation(e);r("start",e,a[n])}}}function touchmoved(e){var t,n,r=e.changedTouches,a=r.length;for(t=0;t<a;++t)if(n=g[r[t].identifier]){noevent(e);n("drag",e,r[t])}}function touchended(e){var t,n,r=e.changedTouches,a=r.length;c&&clearTimeout(c);c=setTimeout((function(){c=null}),500);for(t=0;t<a;++t)if(n=g[r[t].identifier]){nopropagation(e);n("end",e,r[t])}}function beforestart(e,t,r,a,o,u){var i,c,l,d=v.copy(),f=n(u||r,t);if(null!=(l=s.call(e,new DragEvent("beforestart",{sourceEvent:r,target:drag,identifier:o,active:h,x:f[0],y:f[1],dx:0,dy:0,dispatch:d}),a))){i=l.x-f[0]||0;c=l.y-f[1]||0;return function gesture(r,u,s){var v,m=f;switch(r){case"start":g[o]=gesture,v=h++;break;case"end":delete g[o],--h;case"drag":f=n(s||u,t),v=h;break}d.call(r,e,new DragEvent(r,{sourceEvent:u,subject:l,target:drag,identifier:o,active:v,x:f[0]+i,y:f[1]+c,dx:f[0]-m[0],dy:f[1]-m[1],dispatch:d}),a)}}}drag.filter=function(e){return arguments.length?(l="function"===typeof e?e:constant(!!e),drag):l};drag.container=function(e){return arguments.length?(d="function"===typeof e?e:constant(e),drag):d};drag.subject=function(e){return arguments.length?(s="function"===typeof e?e:constant(e),drag):s};drag.touchable=function(e){return arguments.length?(f="function"===typeof e?e:constant(!!e),drag):f};drag.on=function(){var e=v.on.apply(v,arguments);return e===v?drag:e};drag.clickDistance=function(e){return arguments.length?(m=(e=+e)*e,drag):Math.sqrt(m)};return drag}export{drag,nodrag as dragDisable,yesdrag as dragEnable};
|
import{dispatch as e}from"d3-dispatch";import{select as t,pointer as n}from"d3-selection";const r={passive:false};const a={capture:true,passive:false};function nopropagation(e){e.stopImmediatePropagation()}function noevent(e){e.preventDefault();e.stopImmediatePropagation()}function nodrag(e){var n=e.document.documentElement,r=t(e).on("dragstart.drag",noevent,a);if("onselectstart"in n)r.on("selectstart.drag",noevent,a);else{n.__noselect=n.style.MozUserSelect;n.style.MozUserSelect="none"}}function yesdrag(e,n){var r=e.document.documentElement,o=t(e).on("dragstart.drag",null);if(n){o.on("click.drag",noevent,a);setTimeout((function(){o.on("click.drag",null)}),0)}if("onselectstart"in r)o.on("selectstart.drag",null);else{r.style.MozUserSelect=r.__noselect;delete r.__noselect}}var constant=e=>()=>e;function DragEvent(e,{sourceEvent:t,subject:n,target:r,identifier:a,active:o,x:u,y:i,dx:c,dy:l,dispatch:d}){Object.defineProperties(this,{type:{value:e,enumerable:true,configurable:true},sourceEvent:{value:t,enumerable:true,configurable:true},subject:{value:n,enumerable:true,configurable:true},target:{value:r,enumerable:true,configurable:true},identifier:{value:a,enumerable:true,configurable:true},active:{value:o,enumerable:true,configurable:true},x:{value:u,enumerable:true,configurable:true},y:{value:i,enumerable:true,configurable:true},dx:{value:c,enumerable:true,configurable:true},dy:{value:l,enumerable:true,configurable:true},_:{value:d}})}DragEvent.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function defaultFilter(e){return!e.ctrlKey&&!e.button}function defaultContainer(){return this.parentNode}function defaultSubject(e,t){return null==t?{x:e.x,y:e.y}:t}function defaultTouchable(){return navigator.maxTouchPoints||"ontouchstart"in this}function drag(){var o,u,i,c,l=defaultFilter,d=defaultContainer,s=defaultSubject,f=defaultTouchable,g={},v=e("start","drag","end"),h=0,m=0;function drag(e){e.on("mousedown.drag",mousedowned).filter(f).on("touchstart.drag",touchstarted).on("touchmove.drag",touchmoved,r).on("touchend.drag touchcancel.drag",touchended).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function mousedowned(e,n){if(!c&&l.call(this,e,n)){var r=beforestart(this,d.call(this,e,n),e,n,"mouse");if(r){t(e.view).on("mousemove.drag",mousemoved,a).on("mouseup.drag",mouseupped,a);nodrag(e.view);nopropagation(e);i=false;o=e.clientX;u=e.clientY;r("start",e)}}}function mousemoved(e){noevent(e);if(!i){var t=e.clientX-o,n=e.clientY-u;i=t*t+n*n>m}g.mouse("drag",e)}function mouseupped(e){t(e.view).on("mousemove.drag mouseup.drag",null);yesdrag(e.view,i);noevent(e);g.mouse("end",e)}function touchstarted(e,t){if(l.call(this,e,t)){var n,r,a=e.changedTouches,o=d.call(this,e,t),u=a.length;for(n=0;n<u;++n)if(r=beforestart(this,o,e,t,a[n].identifier,a[n])){nopropagation(e);r("start",e,a[n])}}}function touchmoved(e){var t,n,r=e.changedTouches,a=r.length;for(t=0;t<a;++t)if(n=g[r[t].identifier]){noevent(e);n("drag",e,r[t])}}function touchended(e){var t,n,r=e.changedTouches,a=r.length;c&&clearTimeout(c);c=setTimeout((function(){c=null}),500);for(t=0;t<a;++t)if(n=g[r[t].identifier]){nopropagation(e);n("end",e,r[t])}}function beforestart(e,t,r,a,o,u){var i,c,l,d=v.copy(),f=n(u||r,t);if(null!=(l=s.call(e,new DragEvent("beforestart",{sourceEvent:r,target:drag,identifier:o,active:h,x:f[0],y:f[1],dx:0,dy:0,dispatch:d}),a))){i=l.x-f[0]||0;c=l.y-f[1]||0;return function gesture(r,u,s){var v,m=f;switch(r){case"start":g[o]=gesture,v=h++;break;case"end":delete g[o],--h;case"drag":f=n(s||u,t),v=h;break}d.call(r,e,new DragEvent(r,{sourceEvent:u,subject:l,target:drag,identifier:o,active:v,x:f[0]+i,y:f[1]+c,dx:f[0]-m[0],dy:f[1]-m[1],dispatch:d}),a)}}}drag.filter=function(e){return arguments.length?(l="function"===typeof e?e:constant(!!e),drag):l};drag.container=function(e){return arguments.length?(d="function"===typeof e?e:constant(e),drag):d};drag.subject=function(e){return arguments.length?(s="function"===typeof e?e:constant(e),drag):s};drag.touchable=function(e){return arguments.length?(f="function"===typeof e?e:constant(!!e),drag):f};drag.on=function(){var e=v.on.apply(v,arguments);return e===v?drag:e};drag.clickDistance=function(e){return arguments.length?(m=(e=+e)*e,drag):Math.sqrt(m)};return drag}export{drag,nodrag as dragDisable,yesdrag as dragEnable};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-dsv.js
vendored
2
vendor/javascript/d3-dsv.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-dsv@3.0.1 downloaded from https://ga.jspm.io/npm:d3-dsv@3.0.1/src/index.js
|
||||||
|
|
||||||
var r={},e={},t=34,a=10,o=13;function objectConverter(r){return new Function("d","return {"+r.map((function(r,e){return JSON.stringify(r)+": d["+e+'] || ""'})).join(",")+"}")}function customConverter(r,e){var t=objectConverter(r);return function(a,o){return e(t(a),o,r)}}function inferColumns(r){var e=Object.create(null),t=[];r.forEach((function(r){for(var a in r)a in e||t.push(e[a]=a)}));return t}function pad(r,e){var t=r+"",a=t.length;return a<e?new Array(e-a+1).join(0)+t:t}function formatYear(r){return r<0?"-"+pad(-r,6):r>9999?"+"+pad(r,6):pad(r,4)}function formatDate(r){var e=r.getUTCHours(),t=r.getUTCMinutes(),a=r.getUTCSeconds(),o=r.getUTCMilliseconds();return isNaN(r)?"Invalid Date":formatYear(r.getUTCFullYear(),4)+"-"+pad(r.getUTCMonth()+1,2)+"-"+pad(r.getUTCDate(),2)+(o?"T"+pad(e,2)+":"+pad(t,2)+":"+pad(a,2)+"."+pad(o,3)+"Z":a?"T"+pad(e,2)+":"+pad(t,2)+":"+pad(a,2)+"Z":t||e?"T"+pad(e,2)+":"+pad(t,2)+"Z":"")}function dsv(n){var u=new RegExp('["'+n+"\n\r]"),f=n.charCodeAt(0);function parse(r,e){var t,a,o=parseRows(r,(function(r,o){if(t)return t(r,o-1);a=r,t=e?customConverter(r,e):objectConverter(r)}));o.columns=a||[];return o}function parseRows(n,u){var i,s=[],c=n.length,l=0,d=0,m=c<=0,p=false;n.charCodeAt(c-1)===a&&--c;n.charCodeAt(c-1)===o&&--c;function token(){if(m)return e;if(p)return p=false,r;var u,i,s=l;if(n.charCodeAt(s)===t){while(l++<c&&n.charCodeAt(l)!==t||n.charCodeAt(++l)===t);if((u=l)>=c)m=true;else if((i=n.charCodeAt(l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}return n.slice(s+1,u-1).replace(/""/g,'"')}while(l<c){if((i=n.charCodeAt(u=l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}else if(i!==f)continue;return n.slice(s,u)}return m=true,n.slice(s,c)}while((i=token())!==e){var v=[];while(i!==r&&i!==e)v.push(i),i=token();u&&null==(v=u(v,d++))||s.push(v)}return s}function preformatBody(r,e){return r.map((function(r){return e.map((function(e){return formatValue(r[e])})).join(n)}))}function format(r,e){null==e&&(e=inferColumns(r));return[e.map(formatValue).join(n)].concat(preformatBody(r,e)).join("\n")}function formatBody(r,e){null==e&&(e=inferColumns(r));return preformatBody(r,e).join("\n")}function formatRows(r){return r.map(formatRow).join("\n")}function formatRow(r){return r.map(formatValue).join(n)}function formatValue(r){return null==r?"":r instanceof Date?formatDate(r):u.test(r+="")?'"'+r.replace(/"/g,'""')+'"':r}return{parse:parse,parseRows:parseRows,format:format,formatBody:formatBody,formatRows:formatRows,formatRow:formatRow,formatValue:formatValue}}var n=dsv(",");var u=n.parse;var f=n.parseRows;var i=n.format;var s=n.formatBody;var c=n.formatRows;var l=n.formatRow;var d=n.formatValue;var m=dsv("\t");var p=m.parse;var v=m.parseRows;var w=m.format;var C=m.formatBody;var h=m.formatRows;var R=m.formatRow;var g=m.formatValue;function autoType(r){for(var e in r){var t,a,o=r[e].trim();if(o)if("true"===o)o=true;else if("false"===o)o=false;else if("NaN"===o)o=NaN;else if(isNaN(t=+o)){if(!(a=o.match(/^([-+]\d{2})?\d{4}(-\d{2}(-\d{2})?)?(T\d{2}:\d{2}(:\d{2}(\.\d{3})?)?(Z|[-+]\d{2}:\d{2})?)?$/)))continue;!T||!a[4]||a[7]||(o=o.replace(/-/g,"/").replace(/T/," "));o=new Date(o)}else o=t;else o=null;r[e]=o}return r}const T=new Date("2019-01-01T00:00").getHours()||new Date("2019-07-01T00:00").getHours();export{autoType,i as csvFormat,s as csvFormatBody,l as csvFormatRow,c as csvFormatRows,d as csvFormatValue,u as csvParse,f as csvParseRows,dsv as dsvFormat,w as tsvFormat,C as tsvFormatBody,R as tsvFormatRow,h as tsvFormatRows,g as tsvFormatValue,p as tsvParse,v as tsvParseRows};
|
var r={},e={},t=34,a=10,o=13;function objectConverter(r){return new Function("d","return {"+r.map((function(r,e){return JSON.stringify(r)+": d["+e+'] || ""'})).join(",")+"}")}function customConverter(r,e){var t=objectConverter(r);return function(a,o){return e(t(a),o,r)}}function inferColumns(r){var e=Object.create(null),t=[];r.forEach((function(r){for(var a in r)a in e||t.push(e[a]=a)}));return t}function pad(r,e){var t=r+"",a=t.length;return a<e?new Array(e-a+1).join(0)+t:t}function formatYear(r){return r<0?"-"+pad(-r,6):r>9999?"+"+pad(r,6):pad(r,4)}function formatDate(r){var e=r.getUTCHours(),t=r.getUTCMinutes(),a=r.getUTCSeconds(),o=r.getUTCMilliseconds();return isNaN(r)?"Invalid Date":formatYear(r.getUTCFullYear(),4)+"-"+pad(r.getUTCMonth()+1,2)+"-"+pad(r.getUTCDate(),2)+(o?"T"+pad(e,2)+":"+pad(t,2)+":"+pad(a,2)+"."+pad(o,3)+"Z":a?"T"+pad(e,2)+":"+pad(t,2)+":"+pad(a,2)+"Z":t||e?"T"+pad(e,2)+":"+pad(t,2)+"Z":"")}function dsv(n){var u=new RegExp('["'+n+"\n\r]"),f=n.charCodeAt(0);function parse(r,e){var t,a,o=parseRows(r,(function(r,o){if(t)return t(r,o-1);a=r,t=e?customConverter(r,e):objectConverter(r)}));o.columns=a||[];return o}function parseRows(n,u){var i,s=[],c=n.length,l=0,d=0,m=c<=0,p=false;n.charCodeAt(c-1)===a&&--c;n.charCodeAt(c-1)===o&&--c;function token(){if(m)return e;if(p)return p=false,r;var u,i,s=l;if(n.charCodeAt(s)===t){while(l++<c&&n.charCodeAt(l)!==t||n.charCodeAt(++l)===t);if((u=l)>=c)m=true;else if((i=n.charCodeAt(l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}return n.slice(s+1,u-1).replace(/""/g,'"')}while(l<c){if((i=n.charCodeAt(u=l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}else if(i!==f)continue;return n.slice(s,u)}return m=true,n.slice(s,c)}while((i=token())!==e){var v=[];while(i!==r&&i!==e)v.push(i),i=token();u&&null==(v=u(v,d++))||s.push(v)}return s}function preformatBody(r,e){return r.map((function(r){return e.map((function(e){return formatValue(r[e])})).join(n)}))}function format(r,e){null==e&&(e=inferColumns(r));return[e.map(formatValue).join(n)].concat(preformatBody(r,e)).join("\n")}function formatBody(r,e){null==e&&(e=inferColumns(r));return preformatBody(r,e).join("\n")}function formatRows(r){return r.map(formatRow).join("\n")}function formatRow(r){return r.map(formatValue).join(n)}function formatValue(r){return null==r?"":r instanceof Date?formatDate(r):u.test(r+="")?'"'+r.replace(/"/g,'""')+'"':r}return{parse:parse,parseRows:parseRows,format:format,formatBody:formatBody,formatRows:formatRows,formatRow:formatRow,formatValue:formatValue}}var n=dsv(",");var u=n.parse;var f=n.parseRows;var i=n.format;var s=n.formatBody;var c=n.formatRows;var l=n.formatRow;var d=n.formatValue;var m=dsv("\t");var p=m.parse;var v=m.parseRows;var w=m.format;var C=m.formatBody;var h=m.formatRows;var R=m.formatRow;var g=m.formatValue;function autoType(r){for(var e in r){var t,a,o=r[e].trim();if(o)if("true"===o)o=true;else if("false"===o)o=false;else if("NaN"===o)o=NaN;else if(isNaN(t=+o)){if(!(a=o.match(/^([-+]\d{2})?\d{4}(-\d{2}(-\d{2})?)?(T\d{2}:\d{2}(:\d{2}(\.\d{3})?)?(Z|[-+]\d{2}:\d{2})?)?$/)))continue;!T||!a[4]||a[7]||(o=o.replace(/-/g,"/").replace(/T/," "));o=new Date(o)}else o=t;else o=null;r[e]=o}return r}const T=new Date("2019-01-01T00:00").getHours()||new Date("2019-07-01T00:00").getHours();export{autoType,i as csvFormat,s as csvFormatBody,l as csvFormatRow,c as csvFormatRows,d as csvFormatValue,u as csvParse,f as csvParseRows,dsv as dsvFormat,w as tsvFormat,C as tsvFormatBody,R as tsvFormatRow,h as tsvFormatRows,g as tsvFormatValue,p as tsvParse,v as tsvParseRows};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-ease.js
vendored
2
vendor/javascript/d3-ease.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-ease@3.0.1 downloaded from https://ga.jspm.io/npm:d3-ease@3.0.1/src/index.js
|
||||||
|
|
||||||
const linear=t=>+t;function quadIn(t){return t*t}function quadOut(t){return t*(2-t)}function quadInOut(t){return((t*=2)<=1?t*t:--t*(2-t)+1)/2}function cubicIn(t){return t*t*t}function cubicOut(t){return--t*t*t+1}function cubicInOut(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var t=3;var n=function custom(t){t=+t;function polyIn(n){return Math.pow(n,t)}polyIn.exponent=custom;return polyIn}(t);var u=function custom(t){t=+t;function polyOut(n){return 1-Math.pow(1-n,t)}polyOut.exponent=custom;return polyOut}(t);var e=function custom(t){t=+t;function polyInOut(n){return((n*=2)<=1?Math.pow(n,t):2-Math.pow(2-n,t))/2}polyInOut.exponent=custom;return polyInOut}(t);var a=Math.PI,c=a/2;function sinIn(t){return 1===+t?1:1-Math.cos(t*c)}function sinOut(t){return Math.sin(t*c)}function sinInOut(t){return(1-Math.cos(a*t))/2}function tpmt(t){return 1.0009775171065494*(Math.pow(2,-10*t)-.0009765625)}function expIn(t){return tpmt(1-+t)}function expOut(t){return 1-tpmt(t)}function expInOut(t){return((t*=2)<=1?tpmt(1-t):2-tpmt(t-1))/2}function circleIn(t){return 1-Math.sqrt(1-t*t)}function circleOut(t){return Math.sqrt(1- --t*t)}function circleInOut(t){return((t*=2)<=1?1-Math.sqrt(1-t*t):Math.sqrt(1-(t-=2)*t)+1)/2}var s=4/11,r=6/11,o=8/11,i=3/4,O=9/11,I=10/11,p=15/16,f=21/22,l=63/64,m=1/s/s;function bounceIn(t){return 1-bounceOut(1-t)}function bounceOut(t){return(t=+t)<s?m*t*t:t<o?m*(t-=r)*t+i:t<I?m*(t-=O)*t+p:m*(t-=f)*t+l}function bounceInOut(t){return((t*=2)<=1?1-bounceOut(1-t):bounceOut(t-1)+1)/2}var b=1.70158;var h=function custom(t){t=+t;function backIn(n){return(n=+n)*n*(t*(n-1)+n)}backIn.overshoot=custom;return backIn}(b);var M=function custom(t){t=+t;function backOut(n){return--n*n*((n+1)*t+n)+1}backOut.overshoot=custom;return backOut}(b);var v=function custom(t){t=+t;function backInOut(n){return((n*=2)<1?n*n*((t+1)*n-t):(n-=2)*n*((t+1)*n+t)+2)/2}backInOut.overshoot=custom;return backInOut}(b);var x=2*Math.PI,d=1,k=.3;var y=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticIn(e){return t*tpmt(- --e)*Math.sin((u-e)/n)}elasticIn.amplitude=function(t){return custom(t,n*x)};elasticIn.period=function(n){return custom(t,n)};return elasticIn}(d,k);var q=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticOut(e){return 1-t*tpmt(e=+e)*Math.sin((e+u)/n)}elasticOut.amplitude=function(t){return custom(t,n*x)};elasticOut.period=function(n){return custom(t,n)};return elasticOut}(d,k);var B=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticInOut(e){return((e=2*e-1)<0?t*tpmt(-e)*Math.sin((u-e)/n):2-t*tpmt(e)*Math.sin((u+e)/n))/2}elasticInOut.amplitude=function(t){return custom(t,n*x)};elasticInOut.period=function(n){return custom(t,n)};return elasticInOut}(d,k);export{v as easeBack,h as easeBackIn,v as easeBackInOut,M as easeBackOut,bounceOut as easeBounce,bounceIn as easeBounceIn,bounceInOut as easeBounceInOut,bounceOut as easeBounceOut,circleInOut as easeCircle,circleIn as easeCircleIn,circleInOut as easeCircleInOut,circleOut as easeCircleOut,cubicInOut as easeCubic,cubicIn as easeCubicIn,cubicInOut as easeCubicInOut,cubicOut as easeCubicOut,q as easeElastic,y as easeElasticIn,B as easeElasticInOut,q as easeElasticOut,expInOut as easeExp,expIn as easeExpIn,expInOut as easeExpInOut,expOut as easeExpOut,linear as easeLinear,e as easePoly,n as easePolyIn,e as easePolyInOut,u as easePolyOut,quadInOut as easeQuad,quadIn as easeQuadIn,quadInOut as easeQuadInOut,quadOut as easeQuadOut,sinInOut as easeSin,sinIn as easeSinIn,sinInOut as easeSinInOut,sinOut as easeSinOut};
|
const linear=t=>+t;function quadIn(t){return t*t}function quadOut(t){return t*(2-t)}function quadInOut(t){return((t*=2)<=1?t*t:--t*(2-t)+1)/2}function cubicIn(t){return t*t*t}function cubicOut(t){return--t*t*t+1}function cubicInOut(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var t=3;var n=function custom(t){t=+t;function polyIn(n){return Math.pow(n,t)}polyIn.exponent=custom;return polyIn}(t);var u=function custom(t){t=+t;function polyOut(n){return 1-Math.pow(1-n,t)}polyOut.exponent=custom;return polyOut}(t);var e=function custom(t){t=+t;function polyInOut(n){return((n*=2)<=1?Math.pow(n,t):2-Math.pow(2-n,t))/2}polyInOut.exponent=custom;return polyInOut}(t);var a=Math.PI,c=a/2;function sinIn(t){return 1===+t?1:1-Math.cos(t*c)}function sinOut(t){return Math.sin(t*c)}function sinInOut(t){return(1-Math.cos(a*t))/2}function tpmt(t){return 1.0009775171065494*(Math.pow(2,-10*t)-.0009765625)}function expIn(t){return tpmt(1-+t)}function expOut(t){return 1-tpmt(t)}function expInOut(t){return((t*=2)<=1?tpmt(1-t):2-tpmt(t-1))/2}function circleIn(t){return 1-Math.sqrt(1-t*t)}function circleOut(t){return Math.sqrt(1- --t*t)}function circleInOut(t){return((t*=2)<=1?1-Math.sqrt(1-t*t):Math.sqrt(1-(t-=2)*t)+1)/2}var s=4/11,r=6/11,o=8/11,i=3/4,O=9/11,I=10/11,p=15/16,f=21/22,l=63/64,m=1/s/s;function bounceIn(t){return 1-bounceOut(1-t)}function bounceOut(t){return(t=+t)<s?m*t*t:t<o?m*(t-=r)*t+i:t<I?m*(t-=O)*t+p:m*(t-=f)*t+l}function bounceInOut(t){return((t*=2)<=1?1-bounceOut(1-t):bounceOut(t-1)+1)/2}var b=1.70158;var h=function custom(t){t=+t;function backIn(n){return(n=+n)*n*(t*(n-1)+n)}backIn.overshoot=custom;return backIn}(b);var M=function custom(t){t=+t;function backOut(n){return--n*n*((n+1)*t+n)+1}backOut.overshoot=custom;return backOut}(b);var v=function custom(t){t=+t;function backInOut(n){return((n*=2)<1?n*n*((t+1)*n-t):(n-=2)*n*((t+1)*n+t)+2)/2}backInOut.overshoot=custom;return backInOut}(b);var x=2*Math.PI,d=1,k=.3;var y=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticIn(e){return t*tpmt(- --e)*Math.sin((u-e)/n)}elasticIn.amplitude=function(t){return custom(t,n*x)};elasticIn.period=function(n){return custom(t,n)};return elasticIn}(d,k);var q=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticOut(e){return 1-t*tpmt(e=+e)*Math.sin((e+u)/n)}elasticOut.amplitude=function(t){return custom(t,n*x)};elasticOut.period=function(n){return custom(t,n)};return elasticOut}(d,k);var B=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticInOut(e){return((e=2*e-1)<0?t*tpmt(-e)*Math.sin((u-e)/n):2-t*tpmt(e)*Math.sin((u+e)/n))/2}elasticInOut.amplitude=function(t){return custom(t,n*x)};elasticInOut.period=function(n){return custom(t,n)};return elasticInOut}(d,k);export{v as easeBack,h as easeBackIn,v as easeBackInOut,M as easeBackOut,bounceOut as easeBounce,bounceIn as easeBounceIn,bounceInOut as easeBounceInOut,bounceOut as easeBounceOut,circleInOut as easeCircle,circleIn as easeCircleIn,circleInOut as easeCircleInOut,circleOut as easeCircleOut,cubicInOut as easeCubic,cubicIn as easeCubicIn,cubicInOut as easeCubicInOut,cubicOut as easeCubicOut,q as easeElastic,y as easeElasticIn,B as easeElasticInOut,q as easeElasticOut,expInOut as easeExp,expIn as easeExpIn,expInOut as easeExpInOut,expOut as easeExpOut,linear as easeLinear,e as easePoly,n as easePolyIn,e as easePolyInOut,u as easePolyOut,quadInOut as easeQuad,quadIn as easeQuadIn,quadInOut as easeQuadInOut,quadOut as easeQuadOut,sinInOut as easeSin,sinIn as easeSinIn,sinInOut as easeSinInOut,sinOut as easeSinOut};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-fetch.js
vendored
2
vendor/javascript/d3-fetch.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-fetch@3.0.1 downloaded from https://ga.jspm.io/npm:d3-fetch@3.0.1/src/index.js
|
||||||
|
|
||||||
import{dsvFormat as r,csvParse as t,tsvParse as e}from"d3-dsv";function responseBlob(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.blob()}function blob(r,t){return fetch(r,t).then(responseBlob)}function responseArrayBuffer(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.arrayBuffer()}function buffer(r,t){return fetch(r,t).then(responseArrayBuffer)}function responseText(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.text()}function text(r,t){return fetch(r,t).then(responseText)}function dsvParse(r){return function(t,e,n){2===arguments.length&&"function"===typeof e&&(n=e,e=void 0);return text(t,e).then((function(t){return r(t,n)}))}}function dsv(t,e,n,o){3===arguments.length&&"function"===typeof n&&(o=n,n=void 0);var s=r(t);return text(e,n).then((function(r){return s.parse(r,o)}))}var n=dsvParse(t);var o=dsvParse(e);function image(r,t){return new Promise((function(e,n){var o=new Image;for(var s in t)o[s]=t[s];o.onerror=n;o.onload=function(){e(o)};o.src=r}))}function responseJson(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);if(204!==r.status&&205!==r.status)return r.json()}function json(r,t){return fetch(r,t).then(responseJson)}function parser(r){return(t,e)=>text(t,e).then((t=>(new DOMParser).parseFromString(t,r)))}var s=parser("application/xml");var u=parser("text/html");var f=parser("image/svg+xml");export{blob,buffer,n as csv,dsv,u as html,image,json,f as svg,text,o as tsv,s as xml};
|
import{dsvFormat as r,csvParse as t,tsvParse as e}from"d3-dsv";function responseBlob(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.blob()}function blob(r,t){return fetch(r,t).then(responseBlob)}function responseArrayBuffer(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.arrayBuffer()}function buffer(r,t){return fetch(r,t).then(responseArrayBuffer)}function responseText(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);return r.text()}function text(r,t){return fetch(r,t).then(responseText)}function dsvParse(r){return function(t,e,n){2===arguments.length&&"function"===typeof e&&(n=e,e=void 0);return text(t,e).then((function(t){return r(t,n)}))}}function dsv(t,e,n,o){3===arguments.length&&"function"===typeof n&&(o=n,n=void 0);var s=r(t);return text(e,n).then((function(r){return s.parse(r,o)}))}var n=dsvParse(t);var o=dsvParse(e);function image(r,t){return new Promise((function(e,n){var o=new Image;for(var s in t)o[s]=t[s];o.onerror=n;o.onload=function(){e(o)};o.src=r}))}function responseJson(r){if(!r.ok)throw new Error(r.status+" "+r.statusText);if(204!==r.status&&205!==r.status)return r.json()}function json(r,t){return fetch(r,t).then(responseJson)}function parser(r){return(t,e)=>text(t,e).then((t=>(new DOMParser).parseFromString(t,r)))}var s=parser("application/xml");var u=parser("text/html");var f=parser("image/svg+xml");export{blob,buffer,n as csv,dsv,u as html,image,json,f as svg,text,o as tsv,s as xml};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-force.js
vendored
2
vendor/javascript/d3-force.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-format.js
vendored
2
vendor/javascript/d3-format.js
vendored
File diff suppressed because one or more lines are too long
4
vendor/javascript/d3-geo.js
vendored
4
vendor/javascript/d3-geo.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-hierarchy.js
vendored
2
vendor/javascript/d3-hierarchy.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-interpolate.js
vendored
2
vendor/javascript/d3-interpolate.js
vendored
File diff suppressed because one or more lines are too long
4
vendor/javascript/d3-path.js
vendored
4
vendor/javascript/d3-path.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
const t=Math.PI,h=2*t,i=1e-6,s=h-i;function append(t){this._+=t[0];for(let h=1,i=t.length;h<i;++h)this._+=arguments[h]+t[h]}function appendRound(t){let h=Math.floor(t);if(!(h>=0))throw new Error(`invalid digits: ${t}`);if(h>15)return append;const i=10**h;return function(t){this._+=t[0];for(let h=1,s=t.length;h<s;++h)this._+=Math.round(arguments[h]*i)/i+t[h]}}class Path{constructor(t){this._x0=this._y0=this._x1=this._y1=null;this._="";this._append=null==t?append:appendRound(t)}moveTo(t,h){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+h}`}closePath(){if(null!==this._x1){this._x1=this._x0,this._y1=this._y0;this._append`Z`}}lineTo(t,h){this._append`L${this._x1=+t},${this._y1=+h}`}quadraticCurveTo(t,h,i,s){this._append`Q${+t},${+h},${this._x1=+i},${this._y1=+s}`}bezierCurveTo(t,h,i,s,n,a){this._append`C${+t},${+h},${+i},${+s},${this._x1=+n},${this._y1=+a}`}arcTo(h,s,n,a,e){h=+h,s=+s,n=+n,a=+a,e=+e;if(e<0)throw new Error(`negative radius: ${e}`);let _=this._x1,$=this._y1,p=n-h,r=a-s,o=_-h,d=$-s,l=o*o+d*d;if(null===this._x1)this._append`M${this._x1=h},${this._y1=s}`;else if(l>i)if(Math.abs(d*p-r*o)>i&&e){let u=n-_,x=a-$,y=p*p+r*r,M=u*u+x*x,c=Math.sqrt(y),f=Math.sqrt(l),w=e*Math.tan((t-Math.acos((y+l-M)/(2*c*f)))/2),v=w/f,P=w/c;Math.abs(v-1)>i&&this._append`L${h+v*o},${s+v*d}`;this._append`A${e},${e},0,0,${+(d*u>o*x)},${this._x1=h+P*p},${this._y1=s+P*r}`}else this._append`L${this._x1=h},${this._y1=s}`;else;}arc(n,a,e,_,$,p){n=+n,a=+a,e=+e,p=!!p;if(e<0)throw new Error(`negative radius: ${e}`);let r=e*Math.cos(_),o=e*Math.sin(_),d=n+r,l=a+o,u=1^p,x=p?_-$:$-_;null===this._x1?this._append`M${d},${l}`:(Math.abs(this._x1-d)>i||Math.abs(this._y1-l)>i)&&this._append`L${d},${l}`;if(e){x<0&&(x=x%h+h);x>s?this._append`A${e},${e},0,1,${u},${n-r},${a-o}A${e},${e},0,1,${u},${this._x1=d},${this._y1=l}`:x>i&&this._append`A${e},${e},0,${+(x>=t)},${u},${this._x1=n+e*Math.cos($)},${this._y1=a+e*Math.sin($)}`}}rect(t,h,i,s){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+h}h${i=+i}v${+s}h${-i}Z`}toString(){return this._}}function path(){return new Path}path.prototype=Path.prototype;function pathRound(t=3){return new Path(+t)}export{Path,path,pathRound};
|
// d3-path@1.0.9 downloaded from https://ga.jspm.io/npm:d3-path@1.0.9/dist/d3-path.js
|
||||||
|
|
||||||
|
var t="undefined"!==typeof globalThis?globalThis:"undefined"!==typeof self?self:global;var i={};(function(t,h){h(i)})(i,(function(i){var h=Math.PI,s=2*h,_=1e-6,n=s-_;function Path(){(this||t)._x0=(this||t)._y0=(this||t)._x1=(this||t)._y1=null;(this||t)._=""}function path(){return new Path}Path.prototype=path.prototype={constructor:Path,moveTo:function(i,h){(this||t)._+="M"+((this||t)._x0=(this||t)._x1=+i)+","+((this||t)._y0=(this||t)._y1=+h)},closePath:function(){if(null!==(this||t)._x1){(this||t)._x1=(this||t)._x0,(this||t)._y1=(this||t)._y0;(this||t)._+="Z"}},lineTo:function(i,h){(this||t)._+="L"+((this||t)._x1=+i)+","+((this||t)._y1=+h)},quadraticCurveTo:function(i,h,s,_){(this||t)._+="Q"+ +i+","+ +h+","+((this||t)._x1=+s)+","+((this||t)._y1=+_)},bezierCurveTo:function(i,h,s,_,n,a){(this||t)._+="C"+ +i+","+ +h+","+ +s+","+ +_+","+((this||t)._x1=+n)+","+((this||t)._y1=+a)},arcTo:function(i,s,n,a,e){i=+i,s=+s,n=+n,a=+a,e=+e;var o=(this||t)._x1,r=(this||t)._y1,u=n-i,f=a-s,c=o-i,l=r-s,x=c*c+l*l;if(e<0)throw new Error("negative radius: "+e);if(null===(this||t)._x1)(this||t)._+="M"+((this||t)._x1=i)+","+((this||t)._y1=s);else if(x>_)if(Math.abs(l*u-f*c)>_&&e){var y=n-o,M=a-r,p=u*u+f*f,v=y*y+M*M,d=Math.sqrt(p),b=Math.sqrt(x),P=e*Math.tan((h-Math.acos((p+x-v)/(2*d*b)))/2),T=P/b,g=P/d;Math.abs(T-1)>_&&((this||t)._+="L"+(i+T*c)+","+(s+T*l));(this||t)._+="A"+e+","+e+",0,0,"+ +(l*y>c*M)+","+((this||t)._x1=i+g*u)+","+((this||t)._y1=s+g*f)}else(this||t)._+="L"+((this||t)._x1=i)+","+((this||t)._y1=s);else;},arc:function(i,a,e,o,r,u){i=+i,a=+a,e=+e,u=!!u;var f=e*Math.cos(o),c=e*Math.sin(o),l=i+f,x=a+c,y=1^u,M=u?o-r:r-o;if(e<0)throw new Error("negative radius: "+e);null===(this||t)._x1?(this||t)._+="M"+l+","+x:(Math.abs((this||t)._x1-l)>_||Math.abs((this||t)._y1-x)>_)&&((this||t)._+="L"+l+","+x);if(e){M<0&&(M=M%s+s);M>n?(this||t)._+="A"+e+","+e+",0,1,"+y+","+(i-f)+","+(a-c)+"A"+e+","+e+",0,1,"+y+","+((this||t)._x1=l)+","+((this||t)._y1=x):M>_&&((this||t)._+="A"+e+","+e+",0,"+ +(M>=h)+","+y+","+((this||t)._x1=i+e*Math.cos(r))+","+((this||t)._y1=a+e*Math.sin(r)))}},rect:function(i,h,s,_){(this||t)._+="M"+((this||t)._x0=(this||t)._x1=+i)+","+((this||t)._y0=(this||t)._y1=+h)+"h"+ +s+"v"+ +_+"h"+-s+"Z"},toString:function(){return(this||t)._}};i.path=path;Object.defineProperty(i,"__esModule",{value:true})}));const h=i.path,s=i.__esModule;export default i;export{s as __esModule,h as path};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-polygon.js
vendored
2
vendor/javascript/d3-polygon.js
vendored
|
@ -1,2 +1,4 @@
|
||||||
|
// d3-polygon@3.0.1 downloaded from https://ga.jspm.io/npm:d3-polygon@3.0.1/src/index.js
|
||||||
|
|
||||||
function area(n){var r,e=-1,t=n.length,o=n[t-1],l=0;while(++e<t){r=o;o=n[e];l+=r[1]*o[0]-r[0]*o[1]}return l/2}function centroid(n){var r,e,t=-1,o=n.length,l=0,u=0,a=n[o-1],h=0;while(++t<o){r=a;a=n[t];h+=e=r[0]*a[1]-a[0]*r[1];l+=(r[0]+a[0])*e;u+=(r[1]+a[1])*e}return h*=3,[l/h,u/h]}function cross(n,r,e){return(r[0]-n[0])*(e[1]-n[1])-(r[1]-n[1])*(e[0]-n[0])}function lexicographicOrder(n,r){return n[0]-r[0]||n[1]-r[1]}function computeUpperHullIndexes(n){const r=n.length,e=[0,1];let t,o=2;for(t=2;t<r;++t){while(o>1&&cross(n[e[o-2]],n[e[o-1]],n[t])<=0)--o;e[o++]=t}return e.slice(0,o)}function hull(n){if((e=n.length)<3)return null;var r,e,t=new Array(e),o=new Array(e);for(r=0;r<e;++r)t[r]=[+n[r][0],+n[r][1],r];t.sort(lexicographicOrder);for(r=0;r<e;++r)o[r]=[t[r][0],-t[r][1]];var l=computeUpperHullIndexes(t),u=computeUpperHullIndexes(o);var a=u[0]===l[0],h=u[u.length-1]===l[l.length-1],i=[];for(r=l.length-1;r>=0;--r)i.push(n[t[l[r]][2]]);for(r=+a;r<u.length-h;++r)i.push(n[t[u[r]][2]]);return i}function contains(n,r){var e,t,o=n.length,l=n[o-1],u=r[0],a=r[1],h=l[0],i=l[1],c=false;for(var s=0;s<o;++s){l=n[s],e=l[0],t=l[1];t>a!==i>a&&u<(h-e)*(a-t)/(i-t)+e&&(c=!c);h=e,i=t}return c}function length(n){var r,e,t=-1,o=n.length,l=n[o-1],u=l[0],a=l[1],h=0;while(++t<o){r=u;e=a;l=n[t];u=l[0];a=l[1];r-=u;e-=a;h+=Math.hypot(r,e)}return h}export{area as polygonArea,centroid as polygonCentroid,contains as polygonContains,hull as polygonHull,length as polygonLength};
|
function area(n){var r,e=-1,t=n.length,o=n[t-1],l=0;while(++e<t){r=o;o=n[e];l+=r[1]*o[0]-r[0]*o[1]}return l/2}function centroid(n){var r,e,t=-1,o=n.length,l=0,u=0,a=n[o-1],h=0;while(++t<o){r=a;a=n[t];h+=e=r[0]*a[1]-a[0]*r[1];l+=(r[0]+a[0])*e;u+=(r[1]+a[1])*e}return h*=3,[l/h,u/h]}function cross(n,r,e){return(r[0]-n[0])*(e[1]-n[1])-(r[1]-n[1])*(e[0]-n[0])}function lexicographicOrder(n,r){return n[0]-r[0]||n[1]-r[1]}function computeUpperHullIndexes(n){const r=n.length,e=[0,1];let t,o=2;for(t=2;t<r;++t){while(o>1&&cross(n[e[o-2]],n[e[o-1]],n[t])<=0)--o;e[o++]=t}return e.slice(0,o)}function hull(n){if((e=n.length)<3)return null;var r,e,t=new Array(e),o=new Array(e);for(r=0;r<e;++r)t[r]=[+n[r][0],+n[r][1],r];t.sort(lexicographicOrder);for(r=0;r<e;++r)o[r]=[t[r][0],-t[r][1]];var l=computeUpperHullIndexes(t),u=computeUpperHullIndexes(o);var a=u[0]===l[0],h=u[u.length-1]===l[l.length-1],i=[];for(r=l.length-1;r>=0;--r)i.push(n[t[l[r]][2]]);for(r=+a;r<u.length-h;++r)i.push(n[t[u[r]][2]]);return i}function contains(n,r){var e,t,o=n.length,l=n[o-1],u=r[0],a=r[1],h=l[0],i=l[1],c=false;for(var s=0;s<o;++s){l=n[s],e=l[0],t=l[1];t>a!==i>a&&u<(h-e)*(a-t)/(i-t)+e&&(c=!c);h=e,i=t}return c}function length(n){var r,e,t=-1,o=n.length,l=n[o-1],u=l[0],a=l[1],h=0;while(++t<o){r=u;e=a;l=n[t];u=l[0];a=l[1];r-=u;e-=a;h+=Math.hypot(r,e)}return h}export{area as polygonArea,centroid as polygonCentroid,contains as polygonContains,hull as polygonHull,length as polygonLength};
|
||||||
|
|
||||||
|
|
2
vendor/javascript/d3-quadtree.js
vendored
2
vendor/javascript/d3-quadtree.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-random.js
vendored
2
vendor/javascript/d3-random.js
vendored
File diff suppressed because one or more lines are too long
4
vendor/javascript/d3-sankey.js
vendored
Normal file
4
vendor/javascript/d3-sankey.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
vendor/javascript/d3-scale-chromatic.js
vendored
4
vendor/javascript/d3-scale-chromatic.js
vendored
File diff suppressed because one or more lines are too long
2
vendor/javascript/d3-scale.js
vendored
2
vendor/javascript/d3-scale.js
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue