From 9793cc74f901253dd7cf1ac0b0878ff3085632f7 Mon Sep 17 00:00:00 2001 From: Taylor Brazelton Date: Thu, 15 May 2025 04:59:00 -0700 Subject: [PATCH 01/28] Typo fix for piece to place. (#2242) --- app/views/onboardings/goals.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/onboardings/goals.html.erb b/app/views/onboardings/goals.html.erb index f08f51cb..9ea04b07 100644 --- a/app/views/onboardings/goals.html.erb +++ b/app/views/onboardings/goals.html.erb @@ -26,7 +26,7 @@
<% [ - { icon: "layers", label: "See all my accounts in one piece", value: "unified_accounts" }, + { icon: "layers", label: "See all my accounts in one place", value: "unified_accounts" }, { icon: "banknote", label: "Understand cashflow and expenses", value: "cashflow" }, { icon: "pie-chart", label: "Manage financial plans and budgeting", value: "budgeting" }, { icon: "users", label: "Manage finances with a partner", value: "partner" }, From 10dd9e061a5dedb0185686f08acfe6248c9a4879 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 15 May 2025 10:19:56 -0400 Subject: [PATCH 02/28] Improve account sync performance, handle concurrent market data syncing (#2236) * PlaidConnectable concern * Remove bad abstraction * Put sync implementations in own concerns * Sync strategies * Move sync orchestration to Sync class * Clean up sync class, add state machine * Basic market data sync cron * Fix price sync * Improve sync window column names, add timestamps * 30 day syncs by default * Clean up market data methods * Report high duplicate sync counts to Sentry * Add sync states throughout app * account tab session * Persistent account tab selections * Remove manual sleep * Add migration to clear stale syncs on self hosted apps * Tweak sync states * Sync completion event broadcasts * Fix timezones in tests * Cleanup * More cleanup * Plaid item UI broadcasts for sync * Fix account ID namespace conflict * Sync broadcasters * Smoother account sync refreshes * Remove test sync delay --- Gemfile | 5 + Gemfile.lock | 23 ++ app/assets/tailwind/maybe-design-system.css | 4 +- .../maybe-design-system/background-utils.css | 4 + app/components/tabs_component.html.erb | 1 + app/components/tabs_component.rb | 5 +- app/components/tabs_controller.js | 23 +- app/controllers/accounts_controller.rb | 8 - app/controllers/application_controller.rb | 5 +- app/controllers/concerns/notifiable.rb | 2 - .../concerns/restore_layout_preferences.rb | 24 +++ app/controllers/cookie_sessions_controller.rb | 22 ++ .../current_sessions_controller.rb | 14 ++ app/controllers/plaid_items_controller.rb | 4 +- .../controllers/sidebar_tabs_controller.js | 16 -- app/jobs/sync_market_data_job.rb | 7 + app/models/account.rb | 33 ++- app/models/account/sync_complete_event.rb | 54 +++++ app/models/account/syncer.rb | 22 ++ app/models/balance/base_calculator.rb | 35 ---- app/models/balance/forward_calculator.rb | 35 +++- app/models/balance/reverse_calculator.rb | 35 +++- app/models/balance_sheet.rb | 38 +++- app/models/concerns/accountable.rb | 9 - app/models/concerns/syncable.rb | 30 ++- app/models/entry.rb | 2 +- app/models/family.rb | 66 +----- app/models/family/plaid_connectable.rb | 51 +++++ app/models/family/subscribeable.rb | 9 +- app/models/family/sync_complete_event.rb | 21 ++ app/models/family/syncer.rb | 31 +++ app/models/holding/base_calculator.rb | 62 ------ app/models/holding/forward_calculator.rb | 65 +++++- app/models/holding/portfolio_cache.rb | 3 - app/models/holding/reverse_calculator.rb | 54 ++++- app/models/import.rb | 2 +- app/models/market_data_syncer.rb | 196 ++++++++++++++++++ app/models/plaid_item.rb | 160 +------------- app/models/plaid_item/provided.rb | 30 --- app/models/plaid_item/sync_complete_event.rb | 22 ++ app/models/plaid_item/syncer.rb | 149 +++++++++++++ app/models/provider.rb | 2 - app/models/security/provided.rb | 38 ---- app/models/session.rb | 10 + app/models/subscription.rb | 1 + app/models/sync.rb | 127 +++++++++--- app/views/accounts/_account.html.erb | 10 +- .../accounts/_account_sidebar_tabs.html.erb | 124 ++++++----- .../accounts/_accountable_group.html.erb | 91 ++++---- app/views/accounts/_chart_loader.html.erb | 8 +- app/views/accounts/chart.html.erb | 28 +-- app/views/accounts/index.html.erb | 11 - app/views/accounts/index/_account_groups.erb | 7 +- app/views/accounts/show/_chart.html.erb | 17 +- app/views/accounts/show/_template.html.erb | 6 +- app/views/category/dropdowns/show.html.erb | 14 +- app/views/holdings/_cash.html.erb | 17 +- app/views/holdings/_holding.html.erb | 42 +++- app/views/investments/show.html.erb | 32 +-- app/views/layouts/application.html.erb | 7 +- app/views/layouts/shared/_htmldoc.html.erb | 4 - app/views/pages/dashboard.html.erb | 9 +- .../pages/dashboard/_balance_sheet.html.erb | 88 +++++--- .../pages/dashboard/_net_worth_chart.html.erb | 68 +++--- app/views/rules/_rule.html.erb | 4 +- app/views/rules/confirm.html.erb | 4 +- app/views/rules/edit.html.erb | 2 +- app/views/rules/index.html.erb | 2 +- app/views/settings/_settings_nav.html.erb | 32 +-- .../shared/notifications/_loading.html.erb | 9 - app/views/transactions/index.html.erb | 3 - config/initializers/mini_profiler.rb | 3 +- config/initializers/sidekiq.rb | 5 + config/routes.rb | 7 +- config/schedule.yml | 5 + config/sidekiq.yml | 3 +- .../20250512171654_update_sync_timestamps.rb | 65 ++++++ .../20250514214242_add_metadata_to_session.rb | 5 + db/schema.rb | 12 +- test/controllers/accounts_controller_test.rb | 5 - .../current_sessions_controller_test.rb | 15 ++ .../plaid_items_controller_test.rb | 2 +- test/fixtures/syncs.yml | 6 +- test/interfaces/syncable_interface_test.rb | 14 +- test/jobs/sync_job_test.rb | 2 +- test/models/account/entry_test.rb | 6 +- .../models/balance/forward_calculator_test.rb | 20 +- .../models/balance/reverse_calculator_test.rb | 18 +- test/models/family/syncer_test.rb | 30 +++ test/models/family_test.rb | 16 -- .../models/holding/forward_calculator_test.rb | 26 +-- test/models/holding/portfolio_cache_test.rb | 32 +-- .../models/holding/reverse_calculator_test.rb | 30 +-- test/models/market_data_syncer_test.rb | 71 +++++++ test/models/security/price_test.rb | 19 -- test/models/sync_test.rb | 170 +++++++++++++-- test/test_helper.rb | 1 + 97 files changed, 1837 insertions(+), 949 deletions(-) create mode 100644 app/controllers/concerns/restore_layout_preferences.rb create mode 100644 app/controllers/cookie_sessions_controller.rb create mode 100644 app/controllers/current_sessions_controller.rb delete mode 100644 app/javascript/controllers/sidebar_tabs_controller.js create mode 100644 app/jobs/sync_market_data_job.rb create mode 100644 app/models/account/sync_complete_event.rb create mode 100644 app/models/account/syncer.rb delete mode 100644 app/models/balance/base_calculator.rb create mode 100644 app/models/family/plaid_connectable.rb create mode 100644 app/models/family/sync_complete_event.rb create mode 100644 app/models/family/syncer.rb delete mode 100644 app/models/holding/base_calculator.rb create mode 100644 app/models/market_data_syncer.rb delete mode 100644 app/models/plaid_item/provided.rb create mode 100644 app/models/plaid_item/sync_complete_event.rb create mode 100644 app/models/plaid_item/syncer.rb delete mode 100644 app/views/shared/notifications/_loading.html.erb create mode 100644 config/schedule.yml create mode 100644 db/migrate/20250512171654_update_sync_timestamps.rb create mode 100644 db/migrate/20250514214242_add_metadata_to_session.rb create mode 100644 test/controllers/current_sessions_controller_test.rb create mode 100644 test/models/family/syncer_test.rb create mode 100644 test/models/market_data_syncer_test.rb diff --git a/Gemfile b/Gemfile index 26b29fc9..3ad4f0a8 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,7 @@ gem "hotwire_combobox" # Background Jobs gem "sidekiq" +gem "sidekiq-cron" # Monitoring gem "vernier" @@ -63,6 +64,10 @@ gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" gem "activerecord-import" +# State machines +gem "aasm" +gem "after_commit_everywhere", "~> 1.0" + # AI gem "ruby-openai" diff --git a/Gemfile.lock b/Gemfile.lock index 224f8f9c..a8c86724 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,6 +8,8 @@ GIT GEM remote: https://rubygems.org/ specs: + aasm (5.5.0) + concurrent-ruby (~> 1.0) actioncable (7.2.2.1) actionpack (= 7.2.2.1) activesupport (= 7.2.2.1) @@ -83,6 +85,9 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + after_commit_everywhere (1.6.0) + activerecord (>= 4.2) + activesupport ast (2.4.3) aws-eventstream (1.3.2) aws-partitions (1.1093.0) @@ -139,6 +144,9 @@ GEM bigdecimal rexml crass (1.0.6) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) css_parser (1.21.1) addressable csv (3.3.4) @@ -160,6 +168,8 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) + et-orbi (1.2.11) + tzinfo event_stream_parser (1.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) @@ -182,6 +192,9 @@ GEM ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) foreman (0.88.1) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.1.2) @@ -346,6 +359,7 @@ GEM public_suffix (6.0.1) puma (6.6.0) nio4r (~> 2.0) + raabro (1.4.0) racc (1.8.1) rack (3.1.13) rack-mini-profiler (3.3.1) @@ -486,6 +500,11 @@ GEM logger (>= 1.6.2) rack (>= 3.1.0) redis-client (>= 0.23.2) + sidekiq-cron (2.2.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) + globalid (>= 1.0.1) + sidekiq (>= 6.5.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -519,6 +538,7 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -561,7 +581,9 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + aasm activerecord-import + after_commit_everywhere (~> 1.0) aws-sdk-s3 (~> 1.177.0) bcrypt (~> 3.1) benchmark-ips @@ -612,6 +634,7 @@ DEPENDENCIES sentry-ruby sentry-sidekiq sidekiq + sidekiq-cron simplecov skylight stimulus-rails diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 8bf9c6c8..f9dc6039 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -240,7 +240,7 @@ 100% { stroke-dashoffset: 0; } - } + } } /* Specific override for strong tags in prose under dark mode */ @@ -429,5 +429,3 @@ } } - - diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css index fd5f673f..f7244692 100644 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -93,3 +93,7 @@ background-color: var(--color-alpha-black-900); } } + +@utility bg-loader { + @apply bg-surface-inset animate-pulse; +} diff --git a/app/components/tabs_component.html.erb b/app/components/tabs_component.html.erb index 4ec901fa..bfceddad 100644 --- a/app/components/tabs_component.html.erb +++ b/app/components/tabs_component.html.erb @@ -1,6 +1,7 @@ <%= tag.div data: { controller: "tabs", testid: testid, + tabs_session_key_value: session_key, tabs_url_param_key_value: url_param_key, tabs_nav_btn_active_class: active_btn_classes, tabs_nav_btn_inactive_class: inactive_btn_classes diff --git a/app/components/tabs_component.rb b/app/components/tabs_component.rb index 4017b308..747a9420 100644 --- a/app/components/tabs_component.rb +++ b/app/components/tabs_component.rb @@ -27,11 +27,12 @@ class TabsComponent < ViewComponent::Base } } - attr_reader :active_tab, :url_param_key, :variant, :testid + attr_reader :active_tab, :url_param_key, :session_key, :variant, :testid - def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil) + def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil) @active_tab = active_tab @url_param_key = url_param_key + @session_key = session_key @variant = variant.to_sym @active_btn_classes = active_btn_classes @inactive_btn_classes = inactive_btn_classes diff --git a/app/components/tabs_controller.js b/app/components/tabs_controller.js index 43a4b192..259765aa 100644 --- a/app/components/tabs_controller.js +++ b/app/components/tabs_controller.js @@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static classes = ["navBtnActive", "navBtnInactive"]; static targets = ["panel", "navBtn"]; - static values = { urlParamKey: String }; + static values = { sessionKey: String, urlParamKey: String }; show(e) { const btn = e.target.closest("button"); @@ -28,11 +28,30 @@ export default class extends Controller { } }); - // Update URL with the selected tab if (this.urlParamKeyValue) { const url = new URL(window.location.href); url.searchParams.set(this.urlParamKeyValue, selectedTabId); window.history.replaceState({}, "", url); } + + // Update URL with the selected tab + if (this.sessionKeyValue) { + this.#updateSessionPreference(selectedTabId); + } } + + #updateSessionPreference(selectedTabId) { + fetch("/current_session", { + method: "PUT", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, + Accept: "application/json", + }, + body: new URLSearchParams({ + "current_session[tab_key]": this.sessionKeyValue, + "current_session[tab_value]": selectedTabId, + }).toString(), + }); + } } diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index f003ab31..904be2b5 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -26,14 +26,6 @@ class AccountsController < ApplicationController render layout: false end - def sync_all - unless family.syncing? - family.sync_later - end - - redirect_back_or_to accounts_path - end - private def family Current.family diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a54dc088..260579d1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,8 @@ class ApplicationController < ActionController::Base - include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable + include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable, + SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, + FeatureGuardable, Notifiable + include Pagy::Backend before_action :detect_os diff --git a/app/controllers/concerns/notifiable.rb b/app/controllers/concerns/notifiable.rb index 0d8ea384..b1689f67 100644 --- a/app/controllers/concerns/notifiable.rb +++ b/app/controllers/concerns/notifiable.rb @@ -46,8 +46,6 @@ module Notifiable [ { partial: "shared/notifications/alert", locals: { message: data } } ] when "cta" [ resolve_cta(data) ] - when "loading" - [ { partial: "shared/notifications/loading", locals: { message: data } } ] when "notice" messages = Array(data) messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } } diff --git a/app/controllers/concerns/restore_layout_preferences.rb b/app/controllers/concerns/restore_layout_preferences.rb new file mode 100644 index 00000000..284df4cc --- /dev/null +++ b/app/controllers/concerns/restore_layout_preferences.rb @@ -0,0 +1,24 @@ +module RestoreLayoutPreferences + extend ActiveSupport::Concern + + included do + before_action :restore_active_tabs + end + + private + def restore_active_tabs + last_selected_tab = Current.session&.get_preferred_tab("account_sidebar_tab") || "asset" + + @account_group_tab = account_group_tab_param || last_selected_tab + end + + def valid_account_group_tabs + %w[asset liability all] + end + + def account_group_tab_param + param_value = params[:account_sidebar_tab] + return nil unless param_value.in?(valid_account_group_tabs) + param_value + end +end diff --git a/app/controllers/cookie_sessions_controller.rb b/app/controllers/cookie_sessions_controller.rb new file mode 100644 index 00000000..7e76636f --- /dev/null +++ b/app/controllers/cookie_sessions_controller.rb @@ -0,0 +1,22 @@ +class CookieSessionsController < ApplicationController + def update + save_kv_to_session( + cookie_session_params[:tab_key], + cookie_session_params[:tab_value] + ) + + redirect_back_or_to root_path + end + + private + def cookie_session_params + params.require(:cookie_session).permit(:tab_key, :tab_value) + end + + def save_kv_to_session(key, value) + raise "Key must be a string" unless key.is_a?(String) + raise "Value must be a string" unless value.is_a?(String) + + session["custom_#{key}"] = value + end +end diff --git a/app/controllers/current_sessions_controller.rb b/app/controllers/current_sessions_controller.rb new file mode 100644 index 00000000..303b51f0 --- /dev/null +++ b/app/controllers/current_sessions_controller.rb @@ -0,0 +1,14 @@ +class CurrentSessionsController < ApplicationController + def update + if session_params[:tab_key].present? && session_params[:tab_value].present? + Current.session.set_preferred_tab(session_params[:tab_key], session_params[:tab_value]) + end + + head :ok + end + + private + def session_params + params.require(:current_session).permit(:tab_key, :tab_value) + end +end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 37efd5e3..8812cf0f 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -2,8 +2,8 @@ class PlaidItemsController < ApplicationController before_action :set_plaid_item, only: %i[destroy sync] def create - Current.family.plaid_items.create_from_public_token( - plaid_item_params[:public_token], + Current.family.create_plaid_item!( + public_token: plaid_item_params[:public_token], item_name: item_name, region: plaid_item_params[:region] ) diff --git a/app/javascript/controllers/sidebar_tabs_controller.js b/app/javascript/controllers/sidebar_tabs_controller.js deleted file mode 100644 index f88a70f7..00000000 --- a/app/javascript/controllers/sidebar_tabs_controller.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="sidebar-tabs" -export default class extends Controller { - static targets = ["account"]; - - select(event) { - this.accountTargets.forEach((account) => { - if (account.contains(event.target)) { - account.classList.add("bg-container"); - } else { - account.classList.remove("bg-container"); - } - }); - } -} diff --git a/app/jobs/sync_market_data_job.rb b/app/jobs/sync_market_data_job.rb new file mode 100644 index 00000000..074cda9f --- /dev/null +++ b/app/jobs/sync_market_data_job.rb @@ -0,0 +1,7 @@ +class SyncMarketDataJob < ApplicationJob + queue_as :scheduled + + def perform + MarketDataSyncer.new.sync_all + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 352335e0..8c74b83e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,6 +61,18 @@ class Account < ApplicationRecord end end + def syncing? + self_syncing = syncs.incomplete.any? + + # Since Plaid Items sync as a "group", if the item is syncing, even if the account + # sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI. + if linked? + plaid_account&.plaid_item&.syncing? || self_syncing + else + self_syncing + end + end + def institution_domain url_string = plaid_account&.plaid_item&.institution_url return nil unless url_string.present? @@ -81,21 +93,6 @@ class Account < ApplicationRecord DestroyJob.perform_later(self) end - def sync_data(sync, start_date: nil) - Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})") - sync_balances - end - - def post_sync(sync) - family.remove_syncing_notice! - - accountable.post_sync(sync) - - unless sync.child? - family.auto_match_transfers! - end - end - def current_holdings holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end @@ -172,10 +169,4 @@ class Account < ApplicationRecord def long_subtype_label accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end - - private - def sync_balances - strategy = linked? ? :reverse : :forward - Balance::Syncer.new(self, strategy: strategy).sync_balances - end end diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb new file mode 100644 index 00000000..2a64ab80 --- /dev/null +++ b/app/models/account/sync_complete_event.rb @@ -0,0 +1,54 @@ +class Account::SyncCompleteEvent + attr_reader :account + + def initialize(account) + @account = account + end + + def broadcast + # Replace account row in accounts list + account.broadcast_replace_to( + account.family, + target: "account_#{account.id}", + partial: "accounts/account", + locals: { account: account } + ) + + # Replace the groups this account belongs to in the sidebar + account_group_ids.each do |id| + account.broadcast_replace_to( + account.family, + target: id, + partial: "accounts/accountable_group", + locals: { account_group: account_group, open: true } + ) + end + + # If this is a manual, unlinked account (i.e. not part of a Plaid Item), + # trigger the family sync complete broadcast so net worth graph is updated + unless account.linked? + account.family.broadcast_sync_complete + end + + # Refresh entire account page (only applies if currently viewing this account) + account.broadcast_refresh + end + + private + # The sidebar will show the account in both its classification tab and the "all" tab, + # so we need to broadcast to both. + def account_group_ids + id = account_group.id + [ id, "#{account_group.classification}_#{id}" ] + end + + def account_group + family_balance_sheet.account_groups.find do |group| + group.accounts.any? { |a| a.id == account.id } + end + end + + def family_balance_sheet + account.family.balance_sheet + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb new file mode 100644 index 00000000..7e5fabcd --- /dev/null +++ b/app/models/account/syncer.rb @@ -0,0 +1,22 @@ +class Account::Syncer + attr_reader :account + + def initialize(account) + @account = account + end + + def perform_sync(sync) + Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") + sync_balances + end + + def perform_post_sync + account.family.auto_match_transfers! + end + + private + def sync_balances + strategy = account.linked? ? :reverse : :forward + Balance::Syncer.new(account, strategy: strategy).sync_balances + end +end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb deleted file mode 100644 index 2d01dfe7..00000000 --- a/app/models/balance/base_calculator.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Balance::BaseCalculator - attr_reader :account - - def initialize(account) - @account = account - end - - def calculate - Rails.logger.tagged(self.class.name) do - calculate_balances - end - end - - private - def sync_cache - @sync_cache ||= Balance::SyncCache.new(account) - end - - def build_balance(date, cash_balance, holdings_value) - Balance.new( - account_id: account.id, - date: date, - balance: holdings_value + cash_balance, - cash_balance: cash_balance, - currency: account.currency - ) - end - - def calculate_next_balance(prior_balance, transactions, direction: :forward) - flows = transactions.sum(&:amount) - negated = direction == :forward ? account.asset? : account.liability? - flows *= -1 if negated - prior_balance + flows - end -end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index d024d2c6..4e6f2d5c 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,4 +1,16 @@ -class Balance::ForwardCalculator < Balance::BaseCalculator +class Balance::ForwardCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged("Balance::ForwardCalculator") do + calculate_balances + end + end + private def calculate_balances current_cash_balance = 0 @@ -25,4 +37,25 @@ class Balance::ForwardCalculator < Balance::BaseCalculator @balances end + + def sync_cache + @sync_cache ||= Balance::SyncCache.new(account) + end + + def build_balance(date, cash_balance, holdings_value) + Balance.new( + account_id: account.id, + date: date, + balance: holdings_value + cash_balance, + cash_balance: cash_balance, + currency: account.currency + ) + end + + def calculate_next_balance(prior_balance, transactions, direction: :forward) + flows = transactions.sum(&:amount) + negated = direction == :forward ? account.asset? : account.liability? + flows *= -1 if negated + prior_balance + flows + end end diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 6a2ba70b..52a05608 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -1,4 +1,16 @@ -class Balance::ReverseCalculator < Balance::BaseCalculator +class Balance::ReverseCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged("Balance::ReverseCalculator") do + calculate_balances + end + end + private def calculate_balances current_cash_balance = account.cash_balance @@ -35,4 +47,25 @@ class Balance::ReverseCalculator < Balance::BaseCalculator @balances end + + def sync_cache + @sync_cache ||= Balance::SyncCache.new(account) + end + + def build_balance(date, cash_balance, holdings_value) + Balance.new( + account_id: account.id, + date: date, + balance: holdings_value + cash_balance, + cash_balance: cash_balance, + currency: account.currency + ) + end + + def calculate_next_balance(prior_balance, transactions, direction: :forward) + flows = transactions.sum(&:amount) + negated = direction == :forward ? account.asset? : account.liability? + flows *= -1 if negated + prior_balance + flows + end end diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index c289f86f..cc50e3da 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -22,20 +22,25 @@ class BalanceSheet end def classification_groups + asset_groups = account_groups("asset") + liability_groups = account_groups("liability") + [ ClassificationGroup.new( key: "asset", display_name: "Assets", icon: "plus", total_money: total_assets_money, - account_groups: account_groups("asset") + account_groups: asset_groups, + syncing?: asset_groups.any?(&:syncing?) ), ClassificationGroup.new( key: "liability", display_name: "Debts", icon: "minus", total_money: total_liabilities_money, - account_groups: account_groups("liability") + account_groups: liability_groups, + syncing?: liability_groups.any?(&:syncing?) ) ] end @@ -43,13 +48,17 @@ class BalanceSheet def account_groups(classification = nil) classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query classification_total = classification_accounts.sum(&:converted_balance) - account_groups = classification_accounts.group_by(&:accountable_type).transform_keys { |k| Accountable.from_type(k) } + account_groups = classification_accounts.group_by(&:accountable_type) + .transform_keys { |k| Accountable.from_type(k) } - account_groups.map do |accountable, accounts| + groups = account_groups.map do |accountable, accounts| group_total = accounts.sum(&:converted_balance) + key = accountable.model_name.param_key + AccountGroup.new( - key: accountable.model_name.param_key, + id: classification ? "#{classification}_#{key}_group" : "#{key}_group", + key: key, name: accountable.display_name, classification: accountable.classification, total: group_total, @@ -57,6 +66,7 @@ class BalanceSheet weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100, missing_rates?: accounts.any? { |a| a.missing_rates? }, color: accountable.color, + syncing?: accounts.any?(&:is_syncing), accounts: accounts.map do |account| account.define_singleton_method(:weight) do classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100 @@ -65,7 +75,13 @@ class BalanceSheet account end.sort_by(&:weight).reverse ) - end.sort_by(&:weight).reverse + end + + groups.sort_by do |group| + manual_order = Accountable::TYPES + type_name = group.key.camelize + manual_order.index(type_name) || Float::INFINITY + end end def net_worth_series(period: Period.last_30_days) @@ -76,9 +92,13 @@ class BalanceSheet family.currency end + def syncing? + classification_groups.any? { |group| group.syncing? } + end + private - ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true) - AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true) + ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true) + AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true) def active_accounts family.accounts.active.with_attached_logo @@ -87,9 +107,11 @@ class BalanceSheet def totals_query @totals_query ||= active_accounts .joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ])) + .joins("LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND (syncs.status = 'pending' OR syncs.status = 'syncing')") .select( "accounts.*", "SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance", + "COUNT(syncs.id) > 0 as is_syncing", ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ]) ) .group(:classification, :accountable_type, :id) diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 9d0ecb34..12ee9888 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -68,15 +68,6 @@ module Accountable end end - def post_sync(sync) - broadcast_replace_to( - account, - target: "chart_account_#{account.id}", - partial: "accounts/show/chart", - locals: { account: account } - ) - end - def display_name self.class.display_name end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index ce3c30fd..8dfa8e41 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -6,24 +6,24 @@ module Syncable end def syncing? - syncs.where(status: [ :syncing, :pending ]).any? + raise NotImplementedError, "Subclasses must implement the syncing? method" end - def sync_later(start_date: nil, parent_sync: nil) - new_sync = syncs.create!(start_date: start_date, parent: parent_sync) + def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil) + new_sync = syncs.create!(parent: parent_sync, window_start_date: window_start_date, window_end_date: window_end_date) SyncJob.perform_later(new_sync) end - def sync(start_date: nil) - syncs.create!(start_date: start_date).perform + def perform_sync(sync) + syncer.perform_sync(sync) end - def sync_data(sync, start_date: nil) - raise NotImplementedError, "Subclasses must implement the `sync_data` method" + def perform_post_sync + syncer.perform_post_sync end - def post_sync(sync) - # no-op, syncable can optionally provide implementation + def broadcast_sync_complete + sync_broadcaster.broadcast end def sync_error @@ -31,7 +31,7 @@ module Syncable end def last_synced_at - latest_sync&.last_ran_at + latest_sync&.completed_at end def last_sync_created_at @@ -40,6 +40,14 @@ module Syncable private def latest_sync - syncs.order(created_at: :desc).first + syncs.ordered.first + end + + def syncer + self.class::Syncer.new(self) + end + + def sync_broadcaster + self.class::SyncCompleteEvent.new(self) end end diff --git a/app/models/entry.rb b/app/models/entry.rb index c07f27cf..36f61c29 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -45,7 +45,7 @@ class Entry < ApplicationRecord def sync_account_later sync_start_date = [ date_previously_was, date ].compact.min unless destroyed? - account.sync_later(start_date: sync_start_date) + account.sync_later(window_start_date: sync_start_date) end def entryable_name_short diff --git a/app/models/family.rb b/app/models/family.rb index a3a73eec..0cfb8b89 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], @@ -15,7 +15,6 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :plaid_items, dependent: :destroy has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy @@ -36,6 +35,15 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } + # If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running. + def syncing? + Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'") + .joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") + .where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id) + .incomplete + .exists? + end + def assigned_merchants merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq Merchant.where(id: merchant_ids) @@ -65,64 +73,10 @@ class Family < ApplicationRecord @income_statement ||= IncomeStatement.new(self) end - def sync_data(sync, start_date: nil) - # We don't rely on this value to guard the app, but keep it eventually consistent - sync_trial_status! - - Rails.logger.info("Syncing accounts for family #{id}") - accounts.manual.each do |account| - account.sync_later(start_date: start_date, parent_sync: sync) - end - - Rails.logger.info("Applying rules for family #{id}") - rules.each do |rule| - rule.apply_later - end - end - - def remove_syncing_notice! - broadcast_remove target: "syncing-notice" - end - - def post_sync(sync) - auto_match_transfers! - broadcast_refresh - end - - # If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice. - # Ignore syncs older than 10 minutes as they are considered "stale" - def syncing? - Sync.where( - "(syncable_type = 'Family' AND syncable_id = ?) OR - (syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR - (syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))", - id, id, id - ).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists? - end - def eu? country != "US" && country != "CA" end - def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) - provider = if region.to_sym == :eu - Provider::Registry.get_provider(:plaid_eu) - else - Provider::Registry.get_provider(:plaid_us) - end - - # early return when no provider - return nil unless provider - - provider.get_link_token( - user_id: id, - webhooks_url: webhooks_url, - redirect_url: redirect_url, - accountable_type: accountable_type, - access_token: access_token - ).link_token - end - def requires_data_provider? # If family has any trades, they need a provider for historical prices return true if trades.any? diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb new file mode 100644 index 00000000..f2a997c8 --- /dev/null +++ b/app/models/family/plaid_connectable.rb @@ -0,0 +1,51 @@ +module Family::PlaidConnectable + extend ActiveSupport::Concern + + included do + has_many :plaid_items, dependent: :destroy + end + + def create_plaid_item!(public_token:, item_name:, region:) + provider = plaid_provider_for_region(region) + + public_token_response = provider.exchange_public_token(public_token) + + plaid_item = plaid_items.create!( + name: item_name, + plaid_id: public_token_response.item_id, + access_token: public_token_response.access_token, + plaid_region: region + ) + + plaid_item.sync_later + + plaid_item + end + + def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) + return nil unless plaid_us || plaid_eu + + provider = plaid_provider_for_region(region) + + provider.get_link_token( + user_id: self.id, + webhooks_url: webhooks_url, + redirect_url: redirect_url, + accountable_type: accountable_type, + access_token: access_token + ).link_token + end + + private + def plaid_us + @plaid ||= Provider::Registry.get_provider(:plaid_us) + end + + def plaid_eu + @plaid_eu ||= Provider::Registry.get_provider(:plaid_eu) + end + + def plaid_provider_for_region(region) + region.to_sym == :eu ? plaid_eu : plaid_us + end +end diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index 4c8624b7..94ee0971 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -72,10 +72,9 @@ module Family::Subscribeable (1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100 end - private - def sync_trial_status! - if subscription&.status == "trialing" && days_left_in_trial < 0 - subscription.update!(status: "paused") - end + def sync_trial_status! + if subscription&.status == "trialing" && days_left_in_trial < 0 + subscription.update!(status: "paused") end + end end diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb new file mode 100644 index 00000000..628841d0 --- /dev/null +++ b/app/models/family/sync_complete_event.rb @@ -0,0 +1,21 @@ +class Family::SyncCompleteEvent + attr_reader :family + + def initialize(family) + @family = family + end + + def broadcast + family.broadcast_replace( + target: "balance-sheet", + partial: "pages/dashboard/balance_sheet", + locals: { balance_sheet: family.balance_sheet } + ) + + family.broadcast_replace( + target: "net-worth-chart", + partial: "pages/dashboard/net_worth_chart", + locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days } + ) + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb new file mode 100644 index 00000000..30ce2ad5 --- /dev/null +++ b/app/models/family/syncer.rb @@ -0,0 +1,31 @@ +class Family::Syncer + attr_reader :family + + def initialize(family) + @family = family + end + + def perform_sync(sync) + # We don't rely on this value to guard the app, but keep it eventually consistent + family.sync_trial_status! + + Rails.logger.info("Applying rules for family #{family.id}") + family.rules.each do |rule| + rule.apply_later + end + + # Schedule child syncs + child_syncables.each do |syncable| + syncable.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date) + end + end + + def perform_post_sync + family.auto_match_transfers! + end + + private + def child_syncables + family.plaid_items + family.accounts.manual + end +end diff --git a/app/models/holding/base_calculator.rb b/app/models/holding/base_calculator.rb deleted file mode 100644 index d9b85d03..00000000 --- a/app/models/holding/base_calculator.rb +++ /dev/null @@ -1,62 +0,0 @@ -class Holding::BaseCalculator - attr_reader :account - - def initialize(account) - @account = account - end - - def calculate - Rails.logger.tagged(self.class.name) do - holdings = calculate_holdings - Holding.gapfill(holdings) - end - end - - private - def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account) - end - - def empty_portfolio - securities = portfolio_cache.get_securities - securities.each_with_object({}) { |security, hash| hash[security.id] = 0 } - end - - def generate_starting_portfolio - empty_portfolio - end - - def transform_portfolio(previous_portfolio, trade_entries, direction: :forward) - new_quantities = previous_portfolio.dup - - trade_entries.each do |trade_entry| - trade = trade_entry.entryable - security_id = trade.security_id - qty_change = trade.qty - qty_change = qty_change * -1 if direction == :reverse - new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change - end - - new_quantities - end - - def build_holdings(portfolio, date, price_source: nil) - portfolio.map do |security_id, qty| - price = portfolio_cache.get_price(security_id, date, source: price_source) - - if price.nil? - next - end - - Holding.new( - account_id: account.id, - security_id: security_id, - date: date, - qty: qty, - price: price.price, - currency: price.currency, - amount: qty * price.price - ) - end.compact - end -end diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index d2f2e8d7..43f91f7a 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,10 +1,12 @@ -class Holding::ForwardCalculator < Holding::BaseCalculator - private - def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account) - end +class Holding::ForwardCalculator + attr_reader :account - def calculate_holdings + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged("Holding::ForwardCalculator") do current_portfolio = generate_starting_portfolio next_portfolio = {} holdings = [] @@ -16,6 +18,55 @@ class Holding::ForwardCalculator < Holding::BaseCalculator current_portfolio = next_portfolio end - holdings + Holding.gapfill(holdings) + end + end + + private + def portfolio_cache + @portfolio_cache ||= Holding::PortfolioCache.new(account) + end + + def empty_portfolio + securities = portfolio_cache.get_securities + securities.each_with_object({}) { |security, hash| hash[security.id] = 0 } + end + + def generate_starting_portfolio + empty_portfolio + end + + def transform_portfolio(previous_portfolio, trade_entries, direction: :forward) + new_quantities = previous_portfolio.dup + + trade_entries.each do |trade_entry| + trade = trade_entry.entryable + security_id = trade.security_id + qty_change = trade.qty + qty_change = qty_change * -1 if direction == :reverse + new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change + end + + new_quantities + end + + def build_holdings(portfolio, date, price_source: nil) + portfolio.map do |security_id, qty| + price = portfolio_cache.get_price(security_id, date, source: price_source) + + if price.nil? + next + end + + Holding.new( + account_id: account.id, + security_id: security_id, + date: date, + qty: qty, + price: price.price, + currency: price.currency, + amount: qty * price.price + ) + end.compact end end diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index 2d67a1d8..9ffed15b 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -83,9 +83,6 @@ class Holding::PortfolioCache securities.each do |security| Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}" - # Load prices from provider to DB - security.sync_provider_prices(start_date: account.start_date) - # High priority prices from DB (synced from provider) db_prices = security.prices.where(date: account.start_date..Date.current).map do |price| PriceWithPriority.new( diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index 62e2dc95..f52184d7 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,4 +1,17 @@ -class Holding::ReverseCalculator < Holding::BaseCalculator +class Holding::ReverseCalculator + attr_reader :account + + def initialize(account) + @account = account + end + + def calculate + Rails.logger.tagged("Holding::ReverseCalculator") do + holdings = calculate_holdings + Holding.gapfill(holdings) + end + end + private # Reverse calculators will use the existing holdings as a source of security ids and prices # since it is common for a provider to supply "current day" holdings but not all the historical @@ -25,6 +38,11 @@ class Holding::ReverseCalculator < Holding::BaseCalculator holdings end + def empty_portfolio + securities = portfolio_cache.get_securities + securities.each_with_object({}) { |security, hash| hash[security.id] = 0 } + end + # Since this is a reverse sync, we start with today's holdings def generate_starting_portfolio holding_quantities = empty_portfolio @@ -37,4 +55,38 @@ class Holding::ReverseCalculator < Holding::BaseCalculator holding_quantities end + + def transform_portfolio(previous_portfolio, trade_entries, direction: :forward) + new_quantities = previous_portfolio.dup + + trade_entries.each do |trade_entry| + trade = trade_entry.entryable + security_id = trade.security_id + qty_change = trade.qty + qty_change = qty_change * -1 if direction == :reverse + new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change + end + + new_quantities + end + + def build_holdings(portfolio, date, price_source: nil) + portfolio.map do |security_id, qty| + price = portfolio_cache.get_price(security_id, date, source: price_source) + + if price.nil? + next + end + + Holding.new( + account_id: account.id, + security_id: security_id, + date: date, + qty: qty, + price: price.price, + currency: price.currency, + amount: qty * price.price + ) + end.compact + end end diff --git a/app/models/import.rb b/app/models/import.rb index 3ea68015..e96d1fc1 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -62,7 +62,7 @@ class Import < ApplicationRecord def publish import! - family.sync + family.sync_later update! status: :complete rescue => error diff --git a/app/models/market_data_syncer.rb b/app/models/market_data_syncer.rb new file mode 100644 index 00000000..d634cd45 --- /dev/null +++ b/app/models/market_data_syncer.rb @@ -0,0 +1,196 @@ +class MarketDataSyncer + DEFAULT_HISTORY_DAYS = 30 + RATE_PROVIDER_NAME = :synth + PRICE_PROVIDER_NAME = :synth + + MissingExchangeRateError = Class.new(StandardError) + InvalidExchangeRateDataError = Class.new(StandardError) + MissingSecurityPriceError = Class.new(StandardError) + InvalidSecurityPriceDataError = Class.new(StandardError) + + class << self + def for(family: nil, account: nil) + new(family: family, account: account) + end + end + + # Syncer can optionally be scoped. Otherwise, it syncs all user data + def initialize(family: nil, account: nil) + @family = family + @account = account + end + + def sync_all(full_history: false) + sync_exchange_rates(full_history: full_history) + sync_prices(full_history: full_history) + end + + def sync_exchange_rates(full_history: false) + unless rate_provider + Rails.logger.warn("No rate provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + return + end + + # Finds distinct currency pairs + entry_pairs = entries_scope.joins(:account) + .where.not("entries.currency = accounts.currency") + .select("entries.currency as source, accounts.currency as target") + .distinct + + # All accounts in currency not equal to the family currency require exchange rates to show a normalized historical graph + account_pairs = accounts_scope.joins(:family) + .where.not("families.currency = accounts.currency") + .select("accounts.currency as source, families.currency as target") + .distinct + + pairs = (entry_pairs + account_pairs).uniq + + pairs.each do |pair| + sync_exchange_rate(from: pair.source, to: pair.target, full_history: full_history) + end + end + + def sync_prices(full_history: false) + unless price_provider + Rails.logger.warn("No price provider configured for MarketDataSyncer.sync_prices, skipping sync") + nil + end + + securities_scope.each do |security| + sync_security_price(security: security, full_history: full_history) + end + end + + private + attr_reader :family, :account + + def accounts_scope + return Account.where(id: account.id) if account + return family.accounts if family + Account.all + end + + def entries_scope + account&.entries || family&.entries || Entry.all + end + + def securities_scope + if account + account.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) + elsif family + family.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) + else + Security.where.not(exchange_operating_mic: nil) + end + end + + def sync_security_price(security:, full_history:) + start_date = full_history ? find_oldest_required_price(security: security) : default_start_date + + Rails.logger.info("Syncing security price for: #{security.ticker}, start_date: #{start_date}, end_date: #{end_date}") + + fetched_prices = price_provider.fetch_security_prices( + security, + start_date: start_date, + end_date: end_date + ) + + unless fetched_prices.success? + error = MissingSecurityPriceError.new( + "#{PRICE_PROVIDER_NAME} could not fetch security price for: #{security.ticker} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_prices.error.message}" + ) + + Rails.logger.warn(error.message) + Sentry.capture_exception(error, level: :warning) + + return + end + + prices_for_upsert = fetched_prices.data.map do |price| + if price.security.nil? || price.date.nil? || price.price.nil? || price.currency.nil? + error = InvalidSecurityPriceDataError.new( + "#{PRICE_PROVIDER_NAME} returned invalid price data for security: #{security.ticker} on: #{price.date}. Price data: #{price.inspect}" + ) + + Rails.logger.warn(error.message) + Sentry.capture_exception(error, level: :warning) + + next + end + + { + security_id: price.security.id, + date: price.date, + price: price.price, + currency: price.currency + } + end.compact + + Security::Price.upsert_all( + prices_for_upsert, + unique_by: %i[security_id date currency] + ) + end + + def sync_exchange_rate(from:, to:, full_history:) + start_date = full_history ? find_oldest_required_rate(from_currency: from) : default_start_date + + Rails.logger.info("Syncing exchange rate from: #{from}, to: #{to}, start_date: #{start_date}, end_date: #{end_date}") + + fetched_rates = rate_provider.fetch_exchange_rates( + from: from, + to: to, + start_date: start_date, + end_date: end_date + ) + + unless fetched_rates.success? + message = "#{RATE_PROVIDER_NAME} could not fetch exchange rate pair from: #{from} to: #{to} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_rates.error.message}" + Rails.logger.warn(message) + Sentry.capture_exception(MissingExchangeRateError.new(message)) + return + end + + rates_for_upsert = fetched_rates.data.map do |rate| + if rate.from.nil? || rate.to.nil? || rate.date.nil? || rate.rate.nil? + message = "#{RATE_PROVIDER_NAME} returned invalid rate data for pair from: #{from} to: #{to} on: #{rate.date}. Rate data: #{rate.inspect}" + Rails.logger.warn(message) + Sentry.capture_exception(InvalidExchangeRateDataError.new(message)) + next + end + + { + from_currency: rate.from, + to_currency: rate.to, + date: rate.date, + rate: rate.rate + } + end.compact + + ExchangeRate.upsert_all( + rates_for_upsert, + unique_by: %i[from_currency to_currency date] + ) + end + + def rate_provider + Provider::Registry.for_concept(:exchange_rates).get_provider(RATE_PROVIDER_NAME) + end + + def price_provider + Provider::Registry.for_concept(:securities).get_provider(PRICE_PROVIDER_NAME) + end + + def find_oldest_required_rate(from_currency:) + entries_scope.where(currency: from_currency).minimum(:date) || default_start_date + end + + def default_start_date + DEFAULT_HISTORY_DAYS.days.ago.to_date + end + + # Since we're querying market data from a US-based API, end date should always be today (EST) + def end_date + Date.current.in_time_zone("America/New_York").to_date + end +end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 2226f12f..4aae91ca 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,5 +1,5 @@ class PlaidItem < ApplicationRecord - include Provided, Syncable + include Syncable enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good @@ -22,39 +22,6 @@ class PlaidItem < ApplicationRecord scope :ordered, -> { order(created_at: :desc) } scope :needs_update, -> { where(status: :requires_update) } - class << self - def create_from_public_token(token, item_name:, region:) - response = plaid_provider_for_region(region).exchange_public_token(token) - - new_plaid_item = create!( - name: item_name, - plaid_id: response.item_id, - access_token: response.access_token, - plaid_region: region - ) - - new_plaid_item.sync_later - end - end - - def sync_data(sync, start_date: nil) - begin - Rails.logger.info("Fetching and loading Plaid data") - fetch_and_load_plaid_data(sync) - update!(status: :good) if requires_update? - - # Schedule account syncs - accounts.each do |account| - account.sync_later(start_date: start_date, parent_sync: sync) - end - - Rails.logger.info("Plaid data fetched and loaded") - rescue Plaid::ApiError => e - handle_plaid_error(e) - raise e - end - end - def get_update_link_token(webhooks_url:, redirect_url:) begin family.get_link_token( @@ -76,9 +43,8 @@ class PlaidItem < ApplicationRecord end end - def post_sync(sync) - auto_match_categories! - family.broadcast_refresh + def build_category_alias_matcher(user_categories) + Provider::Plaid::CategoryAliasMatcher.new(user_categories) end def destroy_later @@ -86,6 +52,14 @@ class PlaidItem < ApplicationRecord DestroyJob.perform_later(self) end + def syncing? + Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") + .joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id") + .where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id) + .incomplete + .exists? + end + def auto_match_categories! if family.categories.none? family.categories.bootstrap! @@ -117,123 +91,11 @@ class PlaidItem < ApplicationRecord end private - def fetch_and_load_plaid_data(sync) - data = {} - - # Log what we're about to fetch - Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)" - - item = plaid_provider.get_item(access_token).item - update!(available_products: item.available_products, billed_products: item.billed_products) - - # Institution details - if item.institution_id.present? - begin - Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}" - institution = plaid_provider.get_institution(item.institution_id) - update!( - institution_id: item.institution_id, - institution_url: institution.institution.url, - institution_color: institution.institution.primary_color - ) - rescue Plaid::ApiError => e - Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}" - end - end - - # Accounts - fetched_accounts = plaid_provider.get_item_accounts(self).accounts - data[:accounts] = fetched_accounts || [] - sync.update!(data: data) - Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})" - - internal_plaid_accounts = fetched_accounts.map do |account| - internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family) - internal_plaid_account.sync_account_data!(account) - internal_plaid_account - end - - # Transactions - fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) - data[:transactions] = fetched_transactions || [] - sync.update!(data: data) - - if fetched_transactions - Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})" - transaction do - internal_plaid_accounts.each do |internal_plaid_account| - added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } - modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id } - removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id } - - internal_plaid_account.sync_transactions!(added:, modified:, removed:) - end - - update!(next_cursor: fetched_transactions.cursor) - end - end - - # Investments - fetched_investments = safe_fetch_plaid_data(:get_item_investments) - data[:investments] = fetched_investments || [] - sync.update!(data: data) - - if fetched_investments - Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})" - transaction do - internal_plaid_accounts.each do |internal_plaid_account| - transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } - holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id } - securities = fetched_investments.securities - - internal_plaid_account.sync_investments!(transactions:, holdings:, securities:) - end - end - end - - # Liabilities - fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) - data[:liabilities] = fetched_liabilities || [] - sync.update!(data: data) - - if fetched_liabilities - Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})" - transaction do - internal_plaid_accounts.each do |internal_plaid_account| - credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id } - mortgage = fetched_liabilities.mortgage&.find { |l| l.account_id == internal_plaid_account.plaid_id } - student = fetched_liabilities.student&.find { |l| l.account_id == internal_plaid_account.plaid_id } - - internal_plaid_account.sync_credit_data!(credit) if credit - internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage - internal_plaid_account.sync_student_loan_data!(student) if student - end - end - end - end - - def safe_fetch_plaid_data(method) - begin - plaid_provider.send(method, self) - rescue Plaid::ApiError => e - Rails.logger.warn("Error fetching #{method} for item #{id}: #{e.message}") - nil - end - end - def remove_plaid_item plaid_provider.remove_item(access_token) rescue StandardError => e Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}") end - def handle_plaid_error(error) - error_body = JSON.parse(error.response_body) - - if error_body["error_code"] == "ITEM_LOGIN_REQUIRED" - update!(status: :requires_update) - end - end - class PlaidConnectionLostError < StandardError; end end diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb deleted file mode 100644 index f2e8ee8f..00000000 --- a/app/models/plaid_item/provided.rb +++ /dev/null @@ -1,30 +0,0 @@ -module PlaidItem::Provided - extend ActiveSupport::Concern - - class_methods do - def plaid_us_provider - Provider::Registry.get_provider(:plaid_us) - end - - def plaid_eu_provider - Provider::Registry.get_provider(:plaid_eu) - end - - def plaid_provider_for_region(region) - region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider - end - end - - def build_category_alias_matcher(user_categories) - Provider::Plaid::CategoryAliasMatcher.new(user_categories) - end - - private - def eu? - raise "eu? is not implemented for #{self.class.name}" - end - - def plaid_provider - eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider - end -end diff --git a/app/models/plaid_item/sync_complete_event.rb b/app/models/plaid_item/sync_complete_event.rb new file mode 100644 index 00000000..ca008a0a --- /dev/null +++ b/app/models/plaid_item/sync_complete_event.rb @@ -0,0 +1,22 @@ +class PlaidItem::SyncCompleteEvent + attr_reader :plaid_item + + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def broadcast + plaid_item.accounts.each do |account| + account.broadcast_sync_complete + end + + plaid_item.broadcast_replace_to( + plaid_item.family, + target: "plaid_item_#{plaid_item.id}", + partial: "plaid_items/plaid_item", + locals: { plaid_item: plaid_item } + ) + + plaid_item.family.broadcast_sync_complete + end +end diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb new file mode 100644 index 00000000..e32b9ed4 --- /dev/null +++ b/app/models/plaid_item/syncer.rb @@ -0,0 +1,149 @@ +class PlaidItem::Syncer + attr_reader :plaid_item + + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def perform_sync(sync) + begin + Rails.logger.info("Fetching and loading Plaid data") + fetch_and_load_plaid_data + plaid_item.update!(status: :good) if plaid_item.requires_update? + + plaid_item.accounts.each do |account| + account.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date) + end + + Rails.logger.info("Plaid data fetched and loaded") + rescue Plaid::ApiError => e + handle_plaid_error(e) + raise e + end + end + + def perform_post_sync + plaid_item.auto_match_categories! + end + + private + def plaid + plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us + end + + def plaid_eu + @plaid_eu ||= Provider::Registry.get_provider(:plaid_eu) + end + + def plaid_us + @plaid_us ||= Provider::Registry.get_provider(:plaid_us) + end + + def safe_fetch_plaid_data(method) + begin + plaid.send(method, plaid_item) + rescue Plaid::ApiError => e + Rails.logger.warn("Error fetching #{method} for item #{plaid_item.id}: #{e.message}") + nil + end + end + + def handle_plaid_error(error) + error_body = JSON.parse(error.response_body) + + if error_body["error_code"] == "ITEM_LOGIN_REQUIRED" + plaid_item.update!(status: :requires_update) + end + end + + def fetch_and_load_plaid_data + data = {} + + # Log what we're about to fetch + Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)" + + item = plaid.get_item(plaid_item.access_token).item + plaid_item.update!(available_products: item.available_products, billed_products: item.billed_products) + + # Institution details + if item.institution_id.present? + begin + Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}" + institution = plaid.get_institution(item.institution_id) + plaid_item.update!( + institution_id: item.institution_id, + institution_url: institution.institution.url, + institution_color: institution.institution.primary_color + ) + rescue Plaid::ApiError => e + Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}" + end + end + + # Accounts + fetched_accounts = plaid.get_item_accounts(plaid_item).accounts + data[:accounts] = fetched_accounts || [] + Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})" + + internal_plaid_accounts = fetched_accounts.map do |account| + internal_plaid_account = plaid_item.plaid_accounts.find_or_create_from_plaid_data!(account, plaid_item.family) + internal_plaid_account.sync_account_data!(account) + internal_plaid_account + end + + # Transactions + fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) + data[:transactions] = fetched_transactions || [] + + if fetched_transactions + Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})" + PlaidItem.transaction do + internal_plaid_accounts.each do |internal_plaid_account| + added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } + modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id } + removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_transactions!(added:, modified:, removed:) + end + + plaid_item.update!(next_cursor: fetched_transactions.cursor) + end + end + + # Investments + fetched_investments = safe_fetch_plaid_data(:get_item_investments) + data[:investments] = fetched_investments || [] + + if fetched_investments + Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})" + PlaidItem.transaction do + internal_plaid_accounts.each do |internal_plaid_account| + transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } + holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id } + securities = fetched_investments.securities + + internal_plaid_account.sync_investments!(transactions:, holdings:, securities:) + end + end + end + + # Liabilities + fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) + data[:liabilities] = fetched_liabilities || [] + + if fetched_liabilities + Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})" + PlaidItem.transaction do + internal_plaid_accounts.each do |internal_plaid_account| + credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id } + mortgage = fetched_liabilities.mortgage&.find { |l| l.account_id == internal_plaid_account.plaid_id } + student = fetched_liabilities.student&.find { |l| l.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_credit_data!(credit) if credit + internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage + internal_plaid_account.sync_student_loan_data!(student) if student + end + end + end + end +end diff --git a/app/models/provider.rb b/app/models/provider.rb index c90866e7..e9702349 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -36,8 +36,6 @@ class Provider default_error_transformer(error) end - Sentry.capture_exception(transformed_error) - Response.new( success?: false, data: nil, diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index b342c9e5..3d344f29 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -28,44 +28,6 @@ module Security::Provided end end - def sync_provider_prices(start_date:, end_date: Date.current) - unless has_prices? - Rails.logger.warn("Security id=#{id} ticker=#{ticker} is not known by provider, skipping price sync") - return 0 - end - - unless provider.present? - Rails.logger.warn("No security provider configured, cannot sync prices for id=#{id} ticker=#{ticker}") - return 0 - end - - response = provider.fetch_security_prices(self, start_date: start_date, end_date: end_date) - - unless response.success? - Rails.logger.error("Provider error for sync_provider_prices with id=#{id} ticker=#{ticker}: #{response.error}") - return 0 - end - - fetched_prices = response.data.map do |price| - { - security_id: price.security.id, - date: price.date, - price: price.price, - currency: price.currency - } - end - - valid_prices = fetched_prices.reject do |price| - is_invalid = price[:date].nil? || price[:price].nil? || price[:currency].nil? - if is_invalid - Rails.logger.warn("Invalid price data for security_id=#{id}: Missing required fields in price record: #{price.inspect}") - end - is_invalid - end - - Security::Price.upsert_all(valid_prices, unique_by: %i[security_id date currency]) - end - def find_or_fetch_price(date: Date.current, cache: true) price = prices.find_by(date: date) diff --git a/app/models/session.rb b/app/models/session.rb index ce26938a..66faa0f5 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -9,4 +9,14 @@ class Session < ApplicationRecord self.user_agent = Current.user_agent self.ip_address = Current.ip_address end + + def get_preferred_tab(tab_key) + data.dig("tab_preferences", tab_key) + end + + def set_preferred_tab(tab_key, tab_value) + data["tab_preferences"] ||= {} + data["tab_preferences"][tab_key] = tab_value + save! + end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 5f96361e..90b5303d 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -17,6 +17,7 @@ class Subscription < ApplicationRecord validates :stripe_id, presence: true, if: :active? validates :trial_ends_at, presence: true, if: :trialing? + validates :family_id, uniqueness: true class << self def new_trial_ends_at diff --git a/app/models/sync.rb b/app/models/sync.rb index 4d923f12..f783bbc2 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -1,4 +1,6 @@ class Sync < ApplicationRecord + include AASM + Error = Class.new(StandardError) belongs_to :syncable, polymorphic: true @@ -6,12 +8,31 @@ class Sync < ApplicationRecord belongs_to :parent, class_name: "Sync", optional: true has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy - enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } - scope :ordered, -> { order(created_at: :desc) } + scope :incomplete, -> { where(status: [ :pending, :syncing ]) } - def child? - parent_id.present? + validate :window_valid + + # Sync state machine + aasm column: :status, timestamps: true do + state :pending, initial: true + state :syncing + state :completed + state :failed + + after_all_transitions :log_status_change + + event :start, after_commit: :report_warnings do + transitions from: :pending, to: :syncing + end + + event :complete do + transitions from: :syncing, to: :completed + end + + event :fail do + transitions from: :syncing, to: :failed + end end def perform @@ -19,43 +40,83 @@ class Sync < ApplicationRecord start! begin - syncable.sync_data(self, start_date: start_date) - - complete! - Rails.logger.info("Sync completed, starting post-sync") - syncable.post_sync(self) - Rails.logger.info("Post-sync completed") - rescue StandardError => error - fail! error, report_error: true + syncable.perform_sync(self) + rescue => e + fail! + update(error: e.message) + report_error(e) + ensure + finalize_if_all_children_finalized end end end - private - def start! - Rails.logger.info("Starting sync") - update! status: :syncing - end + # Finalizes the current sync AND parent (if it exists) + def finalize_if_all_children_finalized + Sync.transaction do + lock! - def complete! - Rails.logger.info("Sync completed") - update! status: :completed, last_ran_at: Time.current - end + # If this is the "parent" and there are still children running, don't finalize. + return unless all_children_finalized? - def fail!(error, report_error: false) - Rails.logger.error("Sync failed: #{error.message}") - - if report_error - Sentry.capture_exception(error) do |scope| - scope.set_context("sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id }) - scope.set_tags(sync_id: id) + if syncing? + if has_failed_children? + fail! + else + complete! end end - update!( - status: :failed, - error: error.message, - last_ran_at: Time.current - ) + # If we make it here, the sync is finalized. Run post-sync, regardless of failure/success. + perform_post_sync + end + + # If this sync has a parent, try to finalize it so the child status propagates up the chain. + parent&.finalize_if_all_children_finalized + end + + private + def log_status_change + Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})") + end + + def has_failed_children? + children.failed.any? + end + + def all_children_finalized? + children.incomplete.empty? + end + + def perform_post_sync + Rails.logger.info("Performing post-sync for #{syncable_type} (#{syncable.id})") + syncable.perform_post_sync + syncable.broadcast_sync_complete + rescue => e + Rails.logger.error("Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}") + report_error(e) + end + + def report_error(error) + Sentry.capture_exception(error) do |scope| + scope.set_tags(sync_id: id) + end + end + + def report_warnings + todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count + + if todays_sync_count > 10 + Sentry.capture_exception( + Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"), + level: :warning + ) + end + end + + def window_valid + if window_start_date && window_end_date && window_start_date > window_end_date + errors.add(:window_end_date, "must be greater than window_start_date") + end end end diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 07ffd3d5..475f953e 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -30,9 +30,13 @@ <% end %>
-

"> - <%= format_money account.balance_money %> -

+ <% if account.syncing? %> +
+ <% else %> +

"> + <%= format_money account.balance_money %> +

+ <% end %> <% unless account.scheduled_for_deletion? %> <%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 9ca2781c..056e7be1 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ -<%# locals: (family:, active_account_group_tab:) %> +<%# locals: (family:, active_tab:, mobile: false) %> -
+
<% if family.missing_data_provider? %>
@@ -21,73 +21,71 @@
<% end %> -
- <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %> - <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "assets", label: "Assets") %> - <% nav.with_btn(id: "debts", label: "Debts") %> - <% nav.with_btn(id: "all", label: "All") %> - <% end %> + <%= render TabsComponent.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "asset", label: "Assets") %> + <% nav.with_btn(id: "liability", label: "Debts") %> + <% nav.with_btn(id: "all", label: "All") %> + <% end %> - <% tabs.with_panel(tab_id: "assets") do %> -
- <%= render LinkComponent.new( - text: "New asset", - variant: "ghost", - href: new_account_path(step: "method_select", classification: "asset"), - icon: "plus", - frame: :modal, - full_width: true, - class: "justify-start" - ) %> - -
- <% family.balance_sheet.account_groups("asset").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
- <% end %> - - <% tabs.with_panel(tab_id: "debts") do %> -
- <%= render LinkComponent.new( - text: "New debt", + <% tabs.with_panel(tab_id: "asset") do %> +
+ <%= render LinkComponent.new( + text: "New asset", variant: "ghost", - href: new_account_path(step: "method_select", classification: "liability"), + href: new_account_path(step: "method_select", classification: "asset"), icon: "plus", frame: :modal, - full_width: true, - class: "justify-start" - ) %> - -
- <% family.balance_sheet.account_groups("liability").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
- <% end %> - - <% tabs.with_panel(tab_id: "all") do %> -
- <%= render LinkComponent.new( - text: "New account", - variant: "ghost", full_width: true, - href: new_account_path(step: "method_select"), - icon: "plus", - frame: :modal, - class: "justify-start" - ) %> + class: "justify-start" + ) %> -
- <% family.balance_sheet.account_groups.each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
+
+ <% family.balance_sheet.account_groups("asset").each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %>
- <% end %> +
<% end %> -
+ + <% tabs.with_panel(tab_id: "liability") do %> +
+ <%= render LinkComponent.new( + text: "New debt", + variant: "ghost", + href: new_account_path(step: "method_select", classification: "liability"), + icon: "plus", + frame: :modal, + full_width: true, + class: "justify-start" + ) %> + +
+ <% family.balance_sheet.account_groups("liability").each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %> +
+
+ <% end %> + + <% tabs.with_panel(tab_id: "all") do %> +
+ <%= render LinkComponent.new( + text: "New account", + variant: "ghost", + full_width: true, + href: new_account_path(step: "method_select"), + icon: "plus", + frame: :modal, + class: "justify-start" + ) %> + +
+ <% family.balance_sheet.account_groups.each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %> +
+
+ <% end %> + <% end %>
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index a1ab2e3a..26633918 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -1,49 +1,69 @@ -<%# locals: (account_group:) %> +<%# locals: (account_group:, mobile: false, open: nil, **args) %> -<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %> - <% disclosure.with_summary_content do %> -
- <%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> +
"> + <% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %> + <%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+ <% if account_group.syncing? %> +
+
+
+
+
+
+ <% else %> + <%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> + <%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %> +
+
+
+ <% end %> + <% end %> +
+ <% end %> - <%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %> -
-
-
- <% end %> -
- <% end %> - -
- <% account_group.accounts.each do |account| %> - <%= link_to account_path(account), +
+ <% account_group.accounts.each do |account| %> + <%= link_to account_path(account), class: class_names( "block flex items-center gap-2 px-3 py-2 rounded-lg", page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover" - ), - data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" }, + ), title: account.name do %> - <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> + <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> -
- <%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %> - <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> -
+
+ <%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %> + <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> +
-
- <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <% if account.syncing? %> +
+
+
+
+
+
+
+
+ <% else %> +
+ <%= 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 %> +
+
+
+ <% end %>
<% end %> -
+ <% end %> <% end %> - <% end %> -
+
-
- <%= render LinkComponent.new( +
+ <%= render LinkComponent.new( href: new_polymorphic_path(account_group.key, step: "method_select"), text: "New #{account_group.name.downcase.singularize}", icon: "plus", @@ -52,5 +72,6 @@ frame: :modal, class: "justify-start" ) %> -
-<% end %> +
+ <% end %> +
diff --git a/app/views/accounts/_chart_loader.html.erb b/app/views/accounts/_chart_loader.html.erb index f6a9c852..b080329f 100644 --- a/app/views/accounts/_chart_loader.html.erb +++ b/app/views/accounts/_chart_loader.html.erb @@ -1,5 +1,7 @@ -
+
+
-
-

Loading...

+ +
+
diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 11dcbaac..b181ef20 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -2,21 +2,25 @@ <% trend = series.trend %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> -
- <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %> -
+ <% if @account.syncing? %> + <%= render "accounts/chart_loader" %> + <% else %> +
+ <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %> +
-
- <% if series.any? %> -
+ <% if series.any? %> +
- <% else %> -
-

<%= t(".data_not_available") %>

-
- <% end %> -
+ <% else %> +
+

<%= t(".data_not_available") %>

+
+ <% end %> +
+ <% end %> <% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index b4c78332..35e647ea 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -2,17 +2,6 @@

<%= t(".accounts") %>

- <% if Rails.env.development? %> - <%= render ButtonComponent.new( - text: "Sync all", - href: sync_all_accounts_path, - method: :post, - variant: "outline", - disabled: Current.family.syncing?, - icon: "refresh-cw", - ) %> - <% end %> - <%= render LinkComponent.new( text: "New account", href: new_account_path(return_to: accounts_path), diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index 6347339c..51ba4d8b 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -6,9 +6,12 @@

<%= Accountable.from_type(group).display_name %>

·

<%= accounts.count %>

-

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

+ + <% unless accounts.any?(&:syncing?) %> +

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

+ <% end %>
-
+
<% accounts.each do |account| %> <%= render account %> <% end %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 08e67e84..0f63c9f1 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,17 +1,24 @@ -<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %> +<%# locals: (account:, tooltip: nil, chart_view: nil, **args) %> <% period = @period || Period.last_30_days %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %> -
+
- <%= tag.p title || default_value_title, class: "text-sm font-medium text-secondary" %> - <%= tooltip %> + <%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %> + + <% if !account.syncing? && account.investment? %> + <%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %> + <% end %>
- <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> + <% if account.syncing? %> +
+ <% else %> + <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> + <% end %>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/accounts/show/_template.html.erb b/app/views/accounts/show/_template.html.erb index cfac9402..efc7ea5a 100644 --- a/app/views/accounts/show/_template.html.erb +++ b/app/views/accounts/show/_template.html.erb @@ -1,8 +1,8 @@ -<%# locals: (account:, header: nil, chart: nil, tabs: nil) %> +<%# locals: (account:, header: nil, chart: nil, chart_view: nil, tabs: nil) %> <%= turbo_stream_from account %> -<%= turbo_frame_tag dom_id(account) do %> +<%= turbo_frame_tag dom_id(account, :container) do %> <%= tag.div class: "space-y-4 pb-32" do %> <% if header.present? %> <%= header %> @@ -13,7 +13,7 @@ <% if chart.present? %> <%= chart %> <% else %> - <%= render "accounts/show/chart", account: account %> + <%= render "accounts/show/chart", account: account, chart_view: chart_view %> <% end %>
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 65b5c9be..3b25083b 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -2,13 +2,13 @@
- " - autocomplete="nope" - type="search" - class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0" - data-list-filter-target="input" - data-action="list-filter#filter" /> + " + autocomplete="nope" + type="search" + class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0" + data-list-filter-target="input" + data-action="list-filter#filter"> <%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
diff --git a/app/views/holdings/_cash.html.erb b/app/views/holdings/_cash.html.erb index a279d9ab..702f40d0 100644 --- a/app/views/holdings/_cash.html.erb +++ b/app/views/holdings/_cash.html.erb @@ -19,8 +19,13 @@
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> - <%= render "shared/progress_circle", progress: cash_weight %> - <%= tag.p number_to_percentage(cash_weight, precision: 1) %> + + <% if account.syncing? %> +
+ <% else %> + <%= render "shared/progress_circle", progress: cash_weight %> + <%= tag.p number_to_percentage(cash_weight, precision: 1) %> + <% end %>
@@ -28,7 +33,13 @@
- <%= tag.p format_money account.cash_balance_money %> + <% if account.syncing? %> +
+
+
+ <% else %> + <%= tag.p format_money account.cash_balance_money %> + <% end %>
diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 5fe0e4c9..c8a2ac59 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -17,7 +17,9 @@
- <% if holding.weight %> + <% if holding.account.syncing? %> +
+ <% elsif holding.weight %> <%= render "shared/progress_circle", progress: holding.weight %> <%= tag.p number_to_percentage(holding.weight, precision: 1) %> <% else %> @@ -26,21 +28,39 @@
- <%= tag.p format_money holding.avg_cost %> - <%= tag.p t(".per_share"), class: "font-normal text-secondary" %> -
- -
- <% if holding.amount_money %> - <%= tag.p format_money holding.amount_money %> + <% if holding.account.syncing? %> +
+
+
<% else %> - <%= tag.p "--", class: "text-secondary" %> + <%= tag.p format_money holding.avg_cost %> + <%= tag.p t(".per_share"), class: "font-normal text-secondary" %> <% end %> - <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
- <% if holding.trend %> + <% if holding.account.syncing? %> +
+
+
+
+ <% else %> + <% if holding.amount_money %> + <%= tag.p format_money holding.amount_money %> + <% else %> + <%= tag.p "--", class: "text-secondary" %> + <% end %> + <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %> + <% end %> +
+ +
+ <% if holding.account.syncing? %> +
+
+
+
+ <% elsif holding.trend %> <%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %> <%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %> <% else %> diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 7bd7da3b..84be8629 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -1,25 +1,7 @@ -<%= turbo_stream_from @account %> - -<%= turbo_frame_tag dom_id(@account) do %> - <%= tag.div class: "space-y-4" do %> - <%= render "accounts/show/header", account: @account %> - - <%= render "accounts/show/chart", - account: @account, - title: t(".chart_title"), - chart_view: @chart_view, - tooltip: render( - "investments/value_tooltip", - balance: @account.balance_money, - holdings: @account.balance_money - @account.cash_balance_money, - cash: @account.cash_balance_money - ) %> - -
- <%= render "accounts/show/tabs", account: @account, tabs: [ - { key: "activity", contents: render("accounts/show/activity", account: @account) }, - { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, - ] %> -
- <% end %> -<% end %> +<%= render "accounts/show/template", + account: @account, + chart_view: @chart_view, + tabs: render("accounts/show/tabs", account: @account, tabs: [ + { key: "activity", contents: render("accounts/show/activity", account: @account) }, + { key: "holdings", contents: render("investments/holdings_tab", account: @account) }, + ]) %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 53e7f666..e8fc202e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -26,7 +26,8 @@ <%= render( "accounts/account_sidebar_tabs", family: Current.family, - active_account_group_tab: params[:account_group_tab] || "assets" + active_tab: @account_group_tab, + mobile: true ) %>
@@ -80,8 +81,8 @@ <%= yield :sidebar %> <% else %>
-
- <%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %> +
+ <%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
<% if Current.family.trialing? && !self_hosted? %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index fadcd396..30887f7b 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -23,10 +23,6 @@ <%= render_flash_notifications %>
- - <% if Current.family&.syncing? %> - <%= render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %> - <% end %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 569caaa2..1f2f347e 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -25,11 +25,14 @@
<% if Current.family.accounts.any? %> -
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %> +
+ <%= render partial: "pages/dashboard/net_worth_chart", locals: { + balance_sheet: @balance_sheet, + period: @period + } %>
<% else %> -
+
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
<% end %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 302ae103..6b4ea525 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -1,6 +1,6 @@ -<%# locals: (balance_sheet:) %> +<%# locals: (balance_sheet:, **args) %> -
+
<% balance_sheet.classification_groups.each do |classification_group| %>

@@ -11,26 +11,38 @@ <% if classification_group.account_groups.any? %> · - <%= classification_group.total_money.format(precision: 0) %> + <% if classification_group.syncing? %> +
+
+
+ <% else %> + <%= classification_group.total_money.format(precision: 0) %> + <% end %> <% end %>

<% if classification_group.account_groups.any? %> +
<% classification_group.account_groups.each do |account_group| %>
<% end %>
-
- <% classification_group.account_groups.each do |account_group| %> -
-
-

<%= account_group.name %>

-

<%= number_to_percentage(account_group.weight, precision: 0) %>

-
- <% end %> -
+ + <% if classification_group.syncing? %> +

Calculating latest balance data...

+ <% else %> +
+ <% classification_group.account_groups.each do |account_group| %> +
+
+

<%= account_group.name %>

+

<%= number_to_percentage(account_group.weight, precision: 0) %>

+
+ <% end %> +
+ <% end %>
@@ -56,15 +68,27 @@

<%= account_group.name %>

-
-
- <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %> -
+ <% if account_group.syncing? %> +
+
+
+
-
-

<%= format_money(account_group.total_money) %>

+
+
+
-
+ <% else %> +
+
+ <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %> +
+ +
+

<%= format_money(account_group.total_money) %>

+
+
+ <% end %>
@@ -76,15 +100,27 @@ <%= link_to account.name, account_path(account) %>
-
-
- <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> -
+ <% if account.syncing? %> +
+
+
+
-
-

<%= format_money(account.balance_money) %>

+
+
+
-
+ <% else %> +
+
+ <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> +
+ +
+

<%= format_money(account.balance_money) %>

+
+
+ <% end %>
<% if idx < account_group.accounts.size - 1 %> diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index a8870cac..a6b65852 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -1,37 +1,55 @@ -<%# locals: (series:, period:) %> +<%# locals: (balance_sheet:, period:, **args) %> -
-
+
+ <% series = balance_sheet.net_worth_series(period: period) %> +
-

<%= t(".title") %>

-

- <%= series.current.format %> -

- <% if series.trend.nil? %> -

<%= t(".data_not_available") %>

- <% else %> - <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> - <% end %> -
-
+
+

<%= t(".title") %>

- <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %> - <%= form.select :period, + <% if balance_sheet.syncing? %> +
+
+
+
+ <% else %> +

+ <%= series.current.format %> +

+ + <% if series.trend.nil? %> +

<%= t(".data_not_available") %>

+ <% else %> + <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> + <% end %> + <% end %> +
+
+ + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :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 %> -
+ <% end %> +
-<% if series.any? %> -
+
+
+
+ <% else %> + <% if series.any? %> +
-<% else %> -
-

<%= t(".data_not_available") %>

-
-<% end %> + <% else %> +
+

<%= t(".data_not_available") %>

+
+ <% end %> + <% end %> +
diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 9307eb80..1dbf9641 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -1,5 +1,5 @@ <%# locals: (rule:) %> -
+
">
<% if rule.name.present? %>

<%= rule.name %>

@@ -49,7 +49,7 @@ <% if rule.effective_date.nil? %> All past and future <%= rule.resource_type.pluralize %> <% else %> - <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime('%b %-d, %Y') %> + <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %> <% end %>

diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index 28be9496..03311791 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -1,5 +1,5 @@ <%= render DialogComponent.new(reload_on_close: true) do |dialog| %> - <% + <% title = if @rule.name.present? "Confirm changes to \"#{@rule.name}\"" else @@ -7,7 +7,7 @@ end %> <% dialog.with_header(title: title) %> - + <% dialog.with_body do %>

You are about to apply this rule to diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb index f73edc90..91dea816 100644 --- a/app/views/rules/edit.html.erb +++ b/app/views/rules/edit.html.erb @@ -1,7 +1,7 @@ <%= link_to "Back to rules", rules_path %> <%= render DialogComponent.new do |dialog| %> - <% + <% title = if @rule.name.present? "Edit #{@rule.resource_type} rule \"#{@rule.name}\"" else diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index d549e0f8..c6ca513f 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -60,7 +60,7 @@

<% @rules.each_with_index do |rule, idx| %> - <%= render "rule", rule: rule%> + <%= render "rule", rule: rule %> <% unless idx == @rules.size - 1 %>
<% end %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 6a80bb87..5aa2c258 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -1,31 +1,31 @@ <% nav_sections = [ { - header: t('.general_section_title'), + header: t(".general_section_title"), items: [ - { label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' }, - { label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' }, - { label: t('.security_label'), path: settings_security_path, icon: 'shield-check' }, - { label: t('.self_hosting_label'), path: settings_hosting_path, icon: 'database', if: self_hosted? }, - { label: t('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? }, - { label: t('.accounts_label'), path: accounts_path, icon: 'layers' }, - { label: t('.imports_label'), path: imports_path, icon: 'download' } + { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, + { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, + { label: t(".security_label"), path: settings_security_path, icon: "shield-check" }, + { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, + { label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? }, + { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, + { label: t(".imports_label"), path: imports_path, icon: "download" } ] }, { - header: t('.transactions_section_title'), + header: t(".transactions_section_title"), items: [ - { label: t('.tags_label'), path: tags_path, icon: 'tags' }, - { label: t('.categories_label'), path: categories_path, icon: 'shapes' }, - { label: t('.rules_label'), path: rules_path, icon: 'git-branch' }, - { label: t('.merchants_label'), path: family_merchants_path, icon: 'store' } + { label: t(".tags_label"), path: tags_path, icon: "tags" }, + { label: t(".categories_label"), path: categories_path, icon: "shapes" }, + { label: t(".rules_label"), path: rules_path, icon: "git-branch" }, + { label: t(".merchants_label"), path: family_merchants_path, icon: "store" } ] }, { - header: t('.other_section_title'), + header: t(".other_section_title"), items: [ - { label: t('.whats_new_label'), path: changelog_path, icon: 'box' }, - { label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' } + { label: t(".whats_new_label"), path: changelog_path, icon: "box" }, + { label: t(".feedback_label"), path: feedback_path, icon: "megaphone" } ] } ] diff --git a/app/views/shared/notifications/_loading.html.erb b/app/views/shared/notifications/_loading.html.erb deleted file mode 100644 index 18eabd89..00000000 --- a/app/views/shared/notifications/_loading.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<%# locals: (message:, id: nil) %> - -<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs" do %> -
- <%= icon "loader", class: "animate-pulse" %> -
- - <%= tag.p message, class: "text-primary text-sm font-medium" %> -<% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 9781672f..52bb9c3d 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -4,9 +4,6 @@
<%= render MenuComponent.new do |menu| %> - <% if Rails.env.development? %> - <% menu.with_item(variant: "button", text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %> - <% end %> <% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %> <% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %> diff --git a/config/initializers/mini_profiler.rb b/config/initializers/mini_profiler.rb index d304a50e..6a79f19e 100644 --- a/config/initializers/mini_profiler.rb +++ b/config/initializers/mini_profiler.rb @@ -1,3 +1,4 @@ Rails.application.configure do - Rack::MiniProfiler.config.skip_paths = [ "/design-system" ] + Rack::MiniProfiler.config.skip_paths = [ "/design-system", "/assets", "/cable", "/manifest", "/favicon.ico", "/hotwire-livereload", "/logo-pwa.png" ] + Rack::MiniProfiler.config.max_traces_to_show = 50 end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 1209a4fa..9040f864 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -7,3 +7,8 @@ Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) end + +Sidekiq::Cron.configure do |config| + # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs + config.reschedule_grace_period = 600 +end diff --git a/config/routes.rb b/config/routes.rb index 8384d116..ec9e2cce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ require "sidekiq/web" +require "sidekiq/cron/web" Rails.application.routes.draw do # MFA routes @@ -25,6 +26,8 @@ Rails.application.routes.draw do get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" + resource :current_session, only: %i[update] + resource :registration, only: %i[new create] resources :sessions, only: %i[new create destroy] resource :password_reset, only: %i[new create edit update] @@ -104,10 +107,6 @@ Rails.application.routes.draw do end resources :accounts, only: %i[index new], shallow: true do - collection do - post :sync_all - end - member do post :sync get :chart diff --git a/config/schedule.yml b/config/schedule.yml new file mode 100644 index 00000000..561e2327 --- /dev/null +++ b/config/schedule.yml @@ -0,0 +1,5 @@ +sync_market_data: + cron: "0 17 * * 1-5" # 5:00 PM EST (1 hour after market close) + class: "SyncMarketDataJob" + queue: "scheduled" + description: "Syncs market data daily at 5:00 PM EST (1 hour after market close)" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 20343e8c..4fce6f00 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,6 +1,7 @@ concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> queues: - - [high_priority, 6] + - [scheduled, 10] # For cron-like jobs (e.g. "daily market data sync") + - [high_priority, 4] - [medium_priority, 2] - [low_priority, 1] - [default, 1] diff --git a/db/migrate/20250512171654_update_sync_timestamps.rb b/db/migrate/20250512171654_update_sync_timestamps.rb new file mode 100644 index 00000000..ac0830b6 --- /dev/null +++ b/db/migrate/20250512171654_update_sync_timestamps.rb @@ -0,0 +1,65 @@ +class UpdateSyncTimestamps < ActiveRecord::Migration[7.2] + def change + # Timestamps, managed by aasm + add_column :syncs, :pending_at, :datetime + add_column :syncs, :syncing_at, :datetime + add_column :syncs, :completed_at, :datetime + add_column :syncs, :failed_at, :datetime + + add_column :syncs, :window_start_date, :date + add_column :syncs, :window_end_date, :date + + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE syncs + SET + completed_at = CASE + WHEN status = 'completed' THEN last_ran_at + ELSE NULL + END, + failed_at = CASE + WHEN status = 'failed' THEN last_ran_at + ELSE NULL + END + SQL + + execute <<-SQL + UPDATE syncs + SET window_start_date = start_date + SQL + + # Due to some recent bugs, some self hosters have syncs that are stuck. + # This manually fails those syncs so they stop seeing syncing UI notices. + if Rails.application.config.app_mode.self_hosted? + puts "Self hosted: Fail syncs older than 2 hours" + execute <<-SQL + UPDATE syncs + SET status = 'failed' + WHERE ( + status = 'syncing' AND + created_at < NOW() - INTERVAL '2 hours' + ) + SQL + end + end + + dir.down do + execute <<-SQL + UPDATE syncs + SET + last_ran_at = COALESCE(completed_at, failed_at) + SQL + + execute <<-SQL + UPDATE syncs + SET start_date = window_start_date + SQL + end + end + + remove_column :syncs, :start_date, :date + remove_column :syncs, :last_ran_at, :datetime + remove_column :syncs, :error_backtrace, :text, array: true + end +end diff --git a/db/migrate/20250514214242_add_metadata_to_session.rb b/db/migrate/20250514214242_add_metadata_to_session.rb new file mode 100644 index 00000000..849cdccf --- /dev/null +++ b/db/migrate/20250514214242_add_metadata_to_session.rb @@ -0,0 +1,5 @@ +class AddMetadataToSession < ActiveRecord::Migration[7.2] + def change + add_column :sessions, :data, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 9cfb546c..7f25cb20 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do +ActiveRecord::Schema[7.2].define(version: 2025_05_14_214242) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -537,6 +537,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do t.uuid "active_impersonator_session_id" t.datetime "subscribed_at" t.jsonb "prev_transaction_page_params", default: {} + t.jsonb "data", default: {} t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["user_id"], name: "index_sessions_on_user_id" end @@ -587,15 +588,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "syncable_type", null: false t.uuid "syncable_id", null: false - t.datetime "last_ran_at" - t.date "start_date" t.string "status", default: "pending" t.string "error" t.jsonb "data" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "error_backtrace", array: true t.uuid "parent_id" + t.datetime "pending_at" + t.datetime "syncing_at" + t.datetime "completed_at" + t.datetime "failed_at" + t.date "window_start_date" + t.date "window_end_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index d85a5ffa..a3d827e8 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -15,9 +15,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest post sync_account_path(@account) assert_redirected_to account_path(@account) end - - test "can sync all accounts" do - post sync_all_accounts_path - assert_redirected_to accounts_path - end end diff --git a/test/controllers/current_sessions_controller_test.rb b/test/controllers/current_sessions_controller_test.rb new file mode 100644 index 00000000..9d498fb3 --- /dev/null +++ b/test/controllers/current_sessions_controller_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class CurrentSessionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + sign_in @user + end + + test "can update the preferred tab for any namespace" do + put current_session_url, params: { current_session: { tab_key: "accounts_sidebar_tab", tab_value: "asset" } } + assert_response :success + session = Session.order(updated_at: :desc).first + assert_equal "asset", session.get_preferred_tab("accounts_sidebar_tab") + end +end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 7aede85c..4cf8e10a 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest test "create" do @plaid_provider = mock - PlaidItem.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider) + Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider) public_token = "public-sandbox-1234" diff --git a/test/fixtures/syncs.yml b/test/fixtures/syncs.yml index 1b010568..5d954280 100644 --- a/test/fixtures/syncs.yml +++ b/test/fixtures/syncs.yml @@ -1,17 +1,17 @@ account: syncable_type: Account syncable: depository - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> plaid_item: syncable_type: PlaidItem syncable: one - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> family: syncable_type: Family syncable: dylan_family - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb index 6613dbcc..a9f1fa7d 100644 --- a/test/interfaces/syncable_interface_test.rb +++ b/test/interfaces/syncable_interface_test.rb @@ -7,18 +7,14 @@ module SyncableInterfaceTest test "can sync later" do assert_difference "@syncable.syncs.count", 1 do assert_enqueued_with job: SyncJob do - @syncable.sync_later + @syncable.sync_later(window_start_date: 2.days.ago.to_date) end end end - test "can sync" do - assert_difference "@syncable.syncs.count", 1 do - @syncable.sync(start_date: 2.days.ago.to_date) - end - end - - test "implements sync_data" do - assert_respond_to @syncable, :sync_data + test "can perform sync" do + mock_sync = mock + @syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once + @syncable.perform_sync(mock_sync) end end diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb index b8d34400..b392b3a3 100644 --- a/test/jobs/sync_job_test.rb +++ b/test/jobs/sync_job_test.rb @@ -4,7 +4,7 @@ class SyncJobTest < ActiveJob::TestCase test "sync is performed" do syncable = accounts(:depository) - sync = syncable.syncs.create!(start_date: 2.days.ago.to_date) + sync = syncable.syncs.create!(window_start_date: 2.days.ago.to_date) sync.expects(:perform).once diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 5a07ad6e..edd55e68 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -30,7 +30,7 @@ class EntryTest < ActiveSupport::TestCase prior_date = @entry.date - 1 @entry.update! date: prior_date - @entry.account.expects(:sync_later).with(start_date: prior_date) + @entry.account.expects(:sync_later).with(window_start_date: prior_date) @entry.sync_account_later end @@ -38,14 +38,14 @@ class EntryTest < ActiveSupport::TestCase prior_date = @entry.date @entry.update! date: @entry.date + 1 - @entry.account.expects(:sync_later).with(start_date: prior_date) + @entry.account.expects(:sync_later).with(window_start_date: prior_date) @entry.sync_account_later end test "triggers sync with correct start date when transaction deleted" do @entry.destroy! - @entry.account.expects(:sync_later).with(start_date: nil) + @entry.account.expects(:sync_later).with(window_start_date: nil) @entry.sync_account_later end diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 24aecdb5..05215c25 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -15,19 +15,19 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "balance generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + # Create a valuation for Jan 3, 2025 + create_valuation(account: @account, date: "2025-01-03", amount: 17000) - # Create a valuation for Jan 3, 2025 - create_valuation(account: @account, date: "2025-01-03", amount: 17000) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ] + calculated = Balance::ForwardCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ] - calculated = Balance::ForwardCalculator.new(@account).calculate - - assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] } + assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] } + end end # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 38ede057..6d73aea8 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -25,18 +25,18 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase test "balance generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + create_valuation(account: @account, date: "2025-01-03", amount: 17000) - create_valuation(account: @account, date: "2025-01-03", amount: 17000) + expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ] + calculated = Balance::ReverseCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ] - calculated = Balance::ReverseCalculator.new(@account).calculate - - assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } + assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } + end end test "valuations sync" do diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb new file mode 100644 index 00000000..7fe01a7e --- /dev/null +++ b/test/models/family/syncer_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class Family::SyncerTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "syncs plaid items and manual accounts" do + family_sync = syncs(:family) + + manual_accounts_count = @family.accounts.manual.count + items_count = @family.plaid_items.count + + syncer = Family::Syncer.new(@family) + + Account.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(manual_accounts_count) + + PlaidItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(items_count) + + syncer.perform_sync(family_sync) + + assert_equal "completed", family_sync.reload.status + end +end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 24876a77..0229aa6e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -1,25 +1,9 @@ require "test_helper" -require "csv" class FamilyTest < ActiveSupport::TestCase - include EntriesTestHelper include SyncableInterfaceTest def setup - @family = families(:empty) @syncable = families(:dylan_family) end - - test "syncs plaid items and manual accounts" do - family_sync = syncs(:family) - - manual_accounts_count = @syncable.accounts.manual.count - items_count = @syncable.plaid_items.count - - Account.any_instance.expects(:sync_later) - .with(start_date: nil, parent_sync: family_sync) - .times(manual_accounts_count) - - @syncable.sync_data(family_sync, start_date: family_sync.start_date) - end end diff --git a/test/models/holding/forward_calculator_test.rb b/test/models/holding/forward_calculator_test.rb index 7a8aaa53..89e9b28c 100644 --- a/test/models/holding/forward_calculator_test.rb +++ b/test/models/holding/forward_calculator_test.rb @@ -20,22 +20,22 @@ class Holding::ForwardCalculatorTest < ActiveSupport::TestCase test "holding generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: "2025-01-02", price: 500) + Security::Price.create!(security: voo, date: "2025-01-03", price: 500) + Security::Price.create!(security: voo, date: "2025-01-04", price: 500) + create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) - voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") - Security::Price.create!(security: voo, date: "2025-01-02", price: 500) - Security::Price.create!(security: voo, date: "2025-01-03", price: 500) - Security::Price.create!(security: voo, date: "2025-01-04", price: 500) - create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] + calculated = Holding::ForwardCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] - calculated = Holding::ForwardCalculator.new(@account).calculate - - assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] } + assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] } + end end test "forward portfolio calculation" do diff --git a/test/models/holding/portfolio_cache_test.rb b/test/models/holding/portfolio_cache_test.rb index 2518e2b7..4677fc41 100644 --- a/test/models/holding/portfolio_cache_test.rb +++ b/test/models/holding/portfolio_cache_test.rb @@ -28,37 +28,18 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase price: db_price ) - expect_provider_prices([], start_date: @account.start_date) - cache = Holding::PortfolioCache.new(@account) assert_equal db_price, cache.get_price(@security.id, Date.current).price end - test "if no price in DB, try fetching from provider" do - Security::Price.delete_all - - provider_price = Security::Price.new( - security: @security, - date: Date.current, - price: 220, - currency: "USD" - ) - - expect_provider_prices([ provider_price ], start_date: @account.start_date) - - cache = Holding::PortfolioCache.new(@account) - assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price - end - - test "if no price from db or provider, try getting the price from trades" do + test "if no price from db, try getting the price from trades" do Security::Price.destroy_all - expect_provider_prices([], start_date: @account.start_date) cache = Holding::PortfolioCache.new(@account) assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price end - test "if no price from db, provider, or trades, search holdings" do + test "if no price from db or trades, search holdings" do Security::Price.delete_all Entry.delete_all @@ -72,16 +53,7 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase currency: "USD" ) - expect_provider_prices([], start_date: @account.start_date) - cache = Holding::PortfolioCache.new(@account, use_holdings: true) assert_equal holding.price, cache.get_price(@security.id, holding.date).price end - - private - def expect_provider_prices(prices, start_date:, end_date: Date.current) - @provider.expects(:fetch_security_prices) - .with(@security, start_date: start_date, end_date: end_date) - .returns(provider_success_response(prices)) - end end diff --git a/test/models/holding/reverse_calculator_test.rb b/test/models/holding/reverse_calculator_test.rb index ec9dc204..785d7b94 100644 --- a/test/models/holding/reverse_calculator_test.rb +++ b/test/models/holding/reverse_calculator_test.rb @@ -20,26 +20,26 @@ class Holding::ReverseCalculatorTest < ActiveSupport::TestCase test "holding generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: "2025-01-02", price: 500) + Security::Price.create!(security: voo, date: "2025-01-03", price: 500) + Security::Price.create!(security: voo, date: "2025-01-04", price: 500) - voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") - Security::Price.create!(security: voo, date: "2025-01-02", price: 500) - Security::Price.create!(security: voo, date: "2025-01-03", price: 500) - Security::Price.create!(security: voo, date: "2025-01-04", price: 500) + # Today's holdings (provided) + @account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD") - # Today's holdings (provided) - @account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD") + create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) - create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] + calculated = Holding::ReverseCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] - calculated = Holding::ReverseCalculator.new(@account).calculate - - assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] } + assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] } + end end # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings diff --git a/test/models/market_data_syncer_test.rb b/test/models/market_data_syncer_test.rb new file mode 100644 index 00000000..299fb82e --- /dev/null +++ b/test/models/market_data_syncer_test.rb @@ -0,0 +1,71 @@ +require "test_helper" +require "ostruct" + +class MarketDataSyncerTest < ActiveSupport::TestCase + include EntriesTestHelper, ProviderTestHelper + + test "syncs exchange rates with upsert" do + empty_db + + family1 = Family.create!(name: "Family 1", currency: "USD") + account1 = family1.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Depository.new) + account2 = family1.accounts.create!(name: "Account 2", currency: "CAD", balance: 100, accountable: Depository.new) + + family2 = Family.create!(name: "Family 2", currency: "EUR") + account3 = family2.accounts.create!(name: "Account 3", currency: "EUR", balance: 100, accountable: Depository.new) + account4 = family2.accounts.create!(name: "Account 4", currency: "USD", balance: 100, accountable: Depository.new) + + mock_provider = mock + Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once + + start_date = 1.month.ago.to_date + end_date = Date.current.in_time_zone("America/New_York").to_date + + # Put an existing rate in DB to test upsert + ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: start_date, rate: 2.0) + + mock_provider.expects(:fetch_exchange_rates) + .with(from: "CAD", to: "USD", start_date: start_date, end_date: end_date) + .returns(provider_success_response([ OpenStruct.new(from: "CAD", to: "USD", date: start_date, rate: 1.0) ])) + + mock_provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: start_date, end_date: end_date) + .returns(provider_success_response([ OpenStruct.new(from: "USD", to: "EUR", date: start_date, rate: 1.0) ])) + + assert_difference "ExchangeRate.count", 1 do + MarketDataSyncer.new.sync_exchange_rates + end + + assert_equal 1.0, ExchangeRate.where(from_currency: "CAD", to_currency: "USD", date: start_date).first.rate + end + + test "syncs security prices with upsert" do + empty_db + + aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + family = Family.create!(name: "Family 1", currency: "USD") + account = family.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Investment.new) + + mock_provider = mock + Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once + + start_date = 1.month.ago.to_date + end_date = Date.current.in_time_zone("America/New_York").to_date + + mock_provider.expects(:fetch_security_prices) + .with(aapl, start_date: start_date, end_date: end_date) + .returns(provider_success_response([ OpenStruct.new(security: aapl, date: start_date, price: 100, currency: "USD") ])) + + assert_difference "Security::Price.count", 1 do + MarketDataSyncer.new.sync_prices + end + end + + private + def empty_db + Invitation.destroy_all + Family.destroy_all + Security.destroy_all + end +end diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index bd150359..14c022fe 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -49,25 +49,6 @@ class Security::PriceTest < ActiveSupport::TestCase assert_not @security.find_or_fetch_price(date: Date.current) end - test "upserts historical prices from provider" do - Security::Price.delete_all - - # Will be overwritten by upsert - Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD") - - expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [ - Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"), - Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"), - Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD") - ]) - - @security.sync_provider_prices(start_date: 2.days.ago.to_date) - - assert_equal 215, @security.prices.find_by(date: Date.current).price - assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price - assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price - end - private def expect_provider_price(security:, price:, date:) @provider.expects(:fetch_security_price) diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 99019146..cbab9ed3 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -1,34 +1,170 @@ require "test_helper" class SyncTest < ActiveSupport::TestCase - setup do - @sync = syncs(:account) - @sync.update(status: "pending") - end + include ActiveJob::TestHelper test "runs successful sync" do - @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).once + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) - assert_equal "pending", @sync.status + syncable.expects(:perform_sync).with(sync).once - previously_ran_at = @sync.last_ran_at + assert_equal "pending", sync.status - @sync.perform + sync.perform - assert @sync.last_ran_at > previously_ran_at - assert_equal "completed", @sync.status + assert sync.completed_at < Time.now + assert_equal "completed", sync.status end test "handles sync errors" do - @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error")) + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) - assert_equal "pending", @sync.status - previously_ran_at = @sync.last_ran_at + syncable.expects(:perform_sync).with(sync).raises(StandardError.new("test sync error")) - @sync.perform + assert_equal "pending", sync.status - assert @sync.last_ran_at > previously_ran_at - assert_equal "failed", @sync.status - assert_equal "test sync error", @sync.error + sync.perform + + assert sync.failed_at < Time.now + assert_equal "failed", sync.status + assert_equal "test sync error", sync.error + end + + test "can run nested syncs that alert the parent when complete" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).once + + family_sync.perform + + assert_equal "syncing", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).once + + plaid_item_sync.perform + + assert_equal "syncing", family_sync.reload.status + assert_equal "syncing", plaid_item_sync.reload.status + + account.expects(:perform_sync).with(account_sync).once + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "completed", plaid_item_sync.reload.status + assert_equal "completed", account_sync.reload.status + assert_equal "completed", family_sync.reload.status + end + + test "failures propagate up the chain" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).once + + family_sync.perform + + assert_equal "syncing", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).once + + plaid_item_sync.perform + + assert_equal "syncing", family_sync.reload.status + assert_equal "syncing", plaid_item_sync.reload.status + + # This error should "bubble up" to the PlaidItem and Family sync results + account.expects(:perform_sync).with(account_sync).raises(StandardError.new("test account sync error")) + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:perform_post_sync).once + + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "failed", plaid_item_sync.reload.status + assert_equal "failed", account_sync.reload.status + assert_equal "failed", family_sync.reload.status + end + + test "parent failure should not change status if child succeeds" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).raises(StandardError.new("test family sync error")) + + family_sync.perform + + assert_equal "failed", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).raises(StandardError.new("test plaid item sync error")) + + plaid_item_sync.perform + + assert_equal "failed", family_sync.reload.status + assert_equal "failed", plaid_item_sync.reload.status + + # Leaf level sync succeeds, but shouldn't change the status of the already-failed parent syncs + account.expects(:perform_sync).with(account_sync).once + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:perform_post_sync).once + + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "failed", plaid_item_sync.reload.status + assert_equal "failed", family_sync.reload.status + assert_equal "completed", account_sync.reload.status end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 23f98faa..7eac9dde 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -17,6 +17,7 @@ require "rails/test_help" require "minitest/mock" require "minitest/autorun" require "mocha/minitest" +require "aasm/minitest" VCR.configure do |config| config.cassette_library_dir = "test/vcr_cassettes" From a565343102ccc682091ad3e8ca872178ebf784c7 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 15 May 2025 10:53:15 -0400 Subject: [PATCH 03/28] Fix account group broadcast reference --- app/models/account/sync_complete_event.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb index 2a64ab80..129e64a3 100644 --- a/app/models/account/sync_complete_event.rb +++ b/app/models/account/sync_complete_event.rb @@ -38,6 +38,8 @@ class Account::SyncCompleteEvent # The sidebar will show the account in both its classification tab and the "all" tab, # so we need to broadcast to both. def account_group_ids + return [] unless account_group.present? + id = account_group.id [ id, "#{account_group.classification}_#{id}" ] end From 9155e737b20f2a1a1098ac814c1338b7ab39562d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 15 May 2025 11:08:27 -0400 Subject: [PATCH 04/28] Capture broadcast error in Sentry --- app/models/account/sync_complete_event.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb index 129e64a3..32315375 100644 --- a/app/models/account/sync_complete_event.rb +++ b/app/models/account/sync_complete_event.rb @@ -1,6 +1,8 @@ class Account::SyncCompleteEvent attr_reader :account + Error = Class.new(StandardError) + def initialize(account) @account = account end @@ -38,7 +40,12 @@ class Account::SyncCompleteEvent # The sidebar will show the account in both its classification tab and the "all" tab, # so we need to broadcast to both. def account_group_ids - return [] unless account_group.present? + unless account_group.present? + error = Error.new("Account #{account.id} is not part of an account group") + Rails.logger.warn(error.message) + Sentry.capture_exception(error, level: :warning) + return [] + end id = account_group.id [ id, "#{account_group.classification}_#{id}" ] From 5efa8268f63ce5dfffb59b1070cfd5f7204dac4d Mon Sep 17 00:00:00 2001 From: Alex Hatzenbuhler Date: Fri, 16 May 2025 10:23:57 -0500 Subject: [PATCH 05/28] Attempt name override (#2244) --- app/models/plaid_account.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4f60013e..4730985d 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -20,7 +20,10 @@ class PlaidAccount < ApplicationRecord internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id) - internal_account.name = plaid_data.name + # Only set the name for new records or if the name is not locked + if internal_account.new_record? || internal_account.enrichable?(:name) + internal_account.name = plaid_data.name + end internal_account.balance = plaid_data.balances.current || plaid_data.balances.available internal_account.currency = plaid_data.balances.iso_currency_code internal_account.accountable = TYPE_MAPPING[plaid_data.type].new From 6917cecf33cdf0de6110108c219ad881f70b8f88 Mon Sep 17 00:00:00 2001 From: Alex Hatzenbuhler Date: Fri, 16 May 2025 10:24:32 -0500 Subject: [PATCH 06/28] Move to 3 decimal place precision for loans (#2245) * Move to 3 decimal place precision for loans * kick for tests * unkick --- app/views/loans/_overview.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb index f04ccc49..e1649583 100644 --- a/app/views/loans/_overview.html.erb +++ b/app/views/loans/_overview.html.erb @@ -11,7 +11,7 @@ <%= summary_card title: t(".interest_rate") do %> <% if account.loan.interest_rate.present? %> - <%= number_to_percentage(account.loan.interest_rate, precision: 2) %> + <%= number_to_percentage(account.loan.interest_rate, precision: 3) %> <% else %> <%= t(".unknown") %> <% end %> From 6dc1d22672708dadb855a4fdf60ef76fea7253de Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 16 May 2025 14:17:56 -0400 Subject: [PATCH 07/28] Market data sync refinements (#2252) * Exchange rate syncer implementation * Security price syncer * Fix issues with provider API * Add back prod schedule * Add back price and exchange rate syncs to account syncs * Remove unused stock_exchanges table --- app/jobs/sync_market_data_job.rb | 17 +- app/models/account.rb | 2 +- app/models/account/convertible.rb | 27 - app/models/account/market_data_syncer.rb | 82 ++ app/models/account/syncer.rb | 15 + app/models/balance/syncer.rb | 2 - app/models/exchange_rate/provided.rb | 28 +- app/models/exchange_rate/syncer.rb | 156 +++ app/models/market_data_syncer.rb | 246 ++-- app/models/provider/security_concept.rb | 10 +- app/models/provider/synth.rb | 68 +- app/models/security/price/syncer.rb | 145 +++ app/models/security/provided.rb | 42 + app/models/stock_exchange.rb | 3 - app/models/trade_builder.rb | 6 +- config/exchanges.yml | 1020 ----------------- config/initializers/sidekiq.rb | 12 +- config/schedule.yml | 5 +- .../20250516180846_remove_stock_exchanges.rb | 11 + db/schema.rb | 23 +- test/fixtures/stock_exchanges.yml | 13 - .../exchange_rate_provider_interface_test.rb | 2 + .../security_provider_interface_test.rb | 12 +- test/models/account/convertible_test.rb | 52 - .../models/account/market_data_syncer_test.rb | 107 ++ test/models/exchange_rate/syncer_test.rb | 148 +++ test/models/exchange_rate_test.rb | 22 - test/models/market_data_syncer_test.rb | 122 +- test/models/security/price/syncer_test.rb | 143 +++ test/vcr_cassettes/synth/exchange_rate.yml | 22 +- test/vcr_cassettes/synth/exchange_rates.yml | 22 +- test/vcr_cassettes/synth/health.yml | 24 +- test/vcr_cassettes/synth/security_info.yml | 22 +- test/vcr_cassettes/synth/security_price.yml | 22 +- test/vcr_cassettes/synth/security_prices.yml | 44 +- test/vcr_cassettes/synth/security_search.yml | 18 +- .../synth/transaction_enrich.yml | 82 -- test/vcr_cassettes/synth/usage.yml | 24 +- 38 files changed, 1206 insertions(+), 1615 deletions(-) delete mode 100644 app/models/account/convertible.rb create mode 100644 app/models/account/market_data_syncer.rb create mode 100644 app/models/exchange_rate/syncer.rb create mode 100644 app/models/security/price/syncer.rb delete mode 100644 app/models/stock_exchange.rb delete mode 100644 config/exchanges.yml create mode 100644 db/migrate/20250516180846_remove_stock_exchanges.rb delete mode 100644 test/fixtures/stock_exchanges.yml delete mode 100644 test/models/account/convertible_test.rb create mode 100644 test/models/account/market_data_syncer_test.rb create mode 100644 test/models/exchange_rate/syncer_test.rb create mode 100644 test/models/security/price/syncer_test.rb delete mode 100644 test/vcr_cassettes/synth/transaction_enrich.yml diff --git a/app/jobs/sync_market_data_job.rb b/app/jobs/sync_market_data_job.rb index 074cda9f..db34a41a 100644 --- a/app/jobs/sync_market_data_job.rb +++ b/app/jobs/sync_market_data_job.rb @@ -1,7 +1,20 @@ +# This job runs daily at market close. See config/schedule.yml for details. +# +# The primary purpose of this job is to: +# 1. Determine what exchange rate pairs, security prices, and other market data all of our users need to view historical account balance data +# 2. For each needed rate/price, fetch from our data provider and upsert to our database +# +# Each individual account sync will still fetch any missing market data that isn't yet synced, but by running +# this job daily, we significantly reduce overlapping account syncs that both need the same market data (e.g. common security like `AAPL`) +# class SyncMarketDataJob < ApplicationJob queue_as :scheduled - def perform - MarketDataSyncer.new.sync_all + def perform(opts) + opts = opts.symbolize_keys + mode = opts.fetch(:mode, :full) + clear_cache = opts.fetch(:clear_cache, false) + + MarketDataSyncer.new(mode: mode, clear_cache: clear_cache).sync end end diff --git a/app/models/account.rb b/app/models/account.rb index 8c74b83e..13734071 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Chartable, Linkable, Convertible, Enrichable + include Syncable, Monetizable, Chartable, Linkable, Enrichable validates :name, :balance, :currency, presence: true diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb deleted file mode 100644 index fde6fa10..00000000 --- a/app/models/account/convertible.rb +++ /dev/null @@ -1,27 +0,0 @@ -module Account::Convertible - extend ActiveSupport::Concern - - def sync_required_exchange_rates - unless requires_exchange_rates? - Rails.logger.info("No exchange rate sync needed for account #{id}") - return - end - - affected_row_count = ExchangeRate.sync_provider_rates( - from: currency, - to: target_currency, - start_date: start_date, - ) - - Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}") - end - - private - def target_currency - family.currency - end - - def requires_exchange_rates? - currency != target_currency - end -end diff --git a/app/models/account/market_data_syncer.rb b/app/models/account/market_data_syncer.rb new file mode 100644 index 00000000..b223d229 --- /dev/null +++ b/app/models/account/market_data_syncer.rb @@ -0,0 +1,82 @@ +class Account::MarketDataSyncer + attr_reader :account + + def initialize(account) + @account = account + end + + def sync_market_data + sync_exchange_rates + sync_security_prices + end + + private + def sync_exchange_rates + return unless needs_exchange_rates? + return unless ExchangeRate.provider + + pair_dates = {} + + # 1. ENTRY-BASED PAIRS – currencies that differ from the account currency + account.entries + .where.not(currency: account.currency) + .group(:currency) + .minimum(:date) + .each do |source_currency, date| + key = [ source_currency, account.currency ] + pair_dates[key] = [ pair_dates[key], date ].compact.min + end + + # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different) + if foreign_account? + key = [ account.currency, account.family.currency ] + pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min + end + + pair_dates.each do |(source, target), start_date| + ExchangeRate.sync_provider_rates( + from: source, + to: target, + start_date: start_date, + end_date: Date.current + ) + end + end + + def sync_security_prices + return unless Security.provider + + account_securities = account.trades.map(&:security).uniq + + return if account_securities.empty? + + account_securities.each do |security| + security.sync_provider_prices( + start_date: first_required_price_date(security), + end_date: Date.current + ) + + security.sync_provider_details + end + end + + # Calculates the first date we require a price for the given security scoped to this account + def first_required_price_date(security) + account.trades.with_entry + .where(security: security) + .where(entries: { account_id: account.id }) + .minimum("entries.date") + end + + def needs_exchange_rates? + has_multi_currency_entries? || foreign_account? + end + + def has_multi_currency_entries? + account.entries.where.not(currency: account.currency).exists? + end + + def foreign_account? + account.currency != account.family.currency + end +end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 7e5fabcd..de63f5e8 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -7,6 +7,7 @@ class Account::Syncer def perform_sync(sync) Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") + sync_market_data sync_balances end @@ -19,4 +20,18 @@ class Account::Syncer strategy = account.linked? ? :reverse : :forward Balance::Syncer.new(account, strategy: strategy).sync_balances end + + # Syncs all the exchange rates + security prices this account needs to display historical chart data + # + # This is a *supplemental* sync. The daily market data sync should have already populated + # a majority or all of this data, so this is often a no-op. + # + # We rescue errors here because if this operation fails, we don't want to fail the entire sync since + # we have reasonable fallbacks for missing market data. + def sync_market_data + Account::MarketDataSyncer.new(account).sync_market_data + rescue => e + Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") + Sentry.capture_exception(e) + end end diff --git a/app/models/balance/syncer.rb b/app/models/balance/syncer.rb index 362b87aa..890bb5f9 100644 --- a/app/models/balance/syncer.rb +++ b/app/models/balance/syncer.rb @@ -19,8 +19,6 @@ class Balance::Syncer if strategy == :forward update_account_info end - - account.sync_required_exchange_rates end end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index dbe87133..5a1b4c60 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -27,29 +27,21 @@ module ExchangeRate::Provided rate end - def sync_provider_rates(from:, to:, start_date:, end_date: Date.current) + # @return [Integer] The number of exchange rates synced + def sync_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false) unless provider.present? Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates") return 0 end - fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date) - - unless fetched_rates.success? - Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}") - return 0 - end - - rates_data = fetched_rates.data.map do |rate| - { - from_currency: rate.from, - to_currency: rate.to, - date: rate.date, - rate: rate.rate - } - end - - ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date]) + ExchangeRate::Syncer.new( + exchange_rate_provider: provider, + from: from, + to: to, + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ).sync_provider_rates end end end diff --git a/app/models/exchange_rate/syncer.rb b/app/models/exchange_rate/syncer.rb new file mode 100644 index 00000000..1f73bc8e --- /dev/null +++ b/app/models/exchange_rate/syncer.rb @@ -0,0 +1,156 @@ +class ExchangeRate::Syncer + MissingExchangeRateError = Class.new(StandardError) + MissingStartRateError = Class.new(StandardError) + + def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false) + @exchange_rate_provider = exchange_rate_provider + @from = from + @to = to + @start_date = start_date + @end_date = normalize_end_date(end_date) + @clear_cache = clear_cache + end + + # Constructs a daily series of rates for the given currency pair for date range + def sync_provider_rates + if !clear_cache && all_rates_exist? + Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping") + return + end + + if clear_cache && provider_rates.empty? + Rails.logger.warn("Could not clear cache for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates") + return + end + + prev_rate_value = start_rate_value + + unless prev_rate_value.present? + error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}") + Rails.logger.error(error.message) + Sentry.capture_exception(error) + return + end + + gapfilled_rates = effective_start_date.upto(end_date).map do |date| + db_rate_value = db_rates[date]&.rate + provider_rate_value = provider_rates[date]&.rate + + chosen_rate = if clear_cache + provider_rate_value || db_rate_value # overwrite when possible + else + db_rate_value || provider_rate_value # fill gaps + end + + # Gapfill with LOCF strategy (last observation carried forward) + if chosen_rate.nil? + chosen_rate = prev_rate_value + end + + prev_rate_value = chosen_rate + + { + from_currency: from, + to_currency: to, + date: date, + rate: chosen_rate + } + end + + upsert_rows(gapfilled_rates) + end + + private + attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache + + def upsert_rows(rows) + batch_size = 200 + + total_upsert_count = 0 + + rows.each_slice(batch_size) do |batch| + upserted_ids = ExchangeRate.upsert_all( + batch, + unique_by: %i[from_currency to_currency date], + returning: [ "id" ] + ) + + total_upsert_count += upserted_ids.count + end + + total_upsert_count + end + + # Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date + def start_rate_value + provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last + db_rate_value = db_rates[start_date]&.rate + provider_rate_value || db_rate_value + end + + # No need to fetch/upsert rates for dates that we already have in the DB + def effective_start_date + return start_date if clear_cache + + first_missing_date = nil + + start_date.upto(end_date) do |date| + unless db_rates.key?(date) + first_missing_date = date + break + end + end + + first_missing_date || end_date + end + + def provider_rates + @provider_rates ||= begin + # Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays) + provider_fetch_start_date = effective_start_date - 5.days + + provider_response = exchange_rate_provider.fetch_exchange_rates( + from: from, + to: to, + start_date: provider_fetch_start_date, + end_date: end_date + ) + + if provider_response.success? + provider_response.data.index_by(&:date) + else + message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}" + Rails.logger.warn(message) + Sentry.capture_exception(MissingExchangeRateError.new(message)) + {} + end + end + end + + def all_rates_exist? + db_count == expected_count + end + + def expected_count + (start_date..end_date).count + end + + def db_count + db_rates.count + end + + def db_rates + @db_rates ||= ExchangeRate.where(from_currency: from, to_currency: to, date: start_date..end_date) + .order(:date) + .to_a + .index_by(&:date) + end + + # Normalizes an end date so that it never exceeds today's date in the + # America/New_York timezone. If the caller passes a future date we clamp + # it to today so that upstream provider calls remain valid and predictable. + def normalize_end_date(requested_end_date) + today_est = Date.current.in_time_zone("America/New_York").to_date + [ requested_end_date, today_est ].min + end +end diff --git a/app/models/market_data_syncer.rb b/app/models/market_data_syncer.rb index d634cd45..70d60b75 100644 --- a/app/models/market_data_syncer.rb +++ b/app/models/market_data_syncer.rb @@ -1,196 +1,132 @@ class MarketDataSyncer - DEFAULT_HISTORY_DAYS = 30 - RATE_PROVIDER_NAME = :synth - PRICE_PROVIDER_NAME = :synth + # By default, our graphs show 1M as the view, so by fetching 31 days, + # we ensure we can always show an accurate default graph + SNAPSHOT_DAYS = 31 - MissingExchangeRateError = Class.new(StandardError) - InvalidExchangeRateDataError = Class.new(StandardError) - MissingSecurityPriceError = Class.new(StandardError) - InvalidSecurityPriceDataError = Class.new(StandardError) + InvalidModeError = Class.new(StandardError) - class << self - def for(family: nil, account: nil) - new(family: family, account: account) - end + def initialize(mode: :full, clear_cache: false) + @mode = set_mode!(mode) + @clear_cache = clear_cache end - # Syncer can optionally be scoped. Otherwise, it syncs all user data - def initialize(family: nil, account: nil) - @family = family - @account = account + def sync + sync_prices + sync_exchange_rates end - def sync_all(full_history: false) - sync_exchange_rates(full_history: full_history) - sync_prices(full_history: full_history) - end - - def sync_exchange_rates(full_history: false) - unless rate_provider - Rails.logger.warn("No rate provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + # Syncs historical security prices (and details) + def sync_prices + unless Security.provider + Rails.logger.warn("No provider configured for MarketDataSyncer.sync_prices, skipping sync") return end - # Finds distinct currency pairs - entry_pairs = entries_scope.joins(:account) - .where.not("entries.currency = accounts.currency") - .select("entries.currency as source, accounts.currency as target") - .distinct + Security.where.not(exchange_operating_mic: nil).find_each do |security| + security.sync_provider_prices( + start_date: get_first_required_price_date(security), + end_date: end_date, + clear_cache: clear_cache + ) - # All accounts in currency not equal to the family currency require exchange rates to show a normalized historical graph - account_pairs = accounts_scope.joins(:family) - .where.not("families.currency = accounts.currency") - .select("accounts.currency as source, families.currency as target") - .distinct - - pairs = (entry_pairs + account_pairs).uniq - - pairs.each do |pair| - sync_exchange_rate(from: pair.source, to: pair.target, full_history: full_history) + security.sync_provider_details(clear_cache: clear_cache) end end - def sync_prices(full_history: false) - unless price_provider - Rails.logger.warn("No price provider configured for MarketDataSyncer.sync_prices, skipping sync") - nil + def sync_exchange_rates + unless ExchangeRate.provider + Rails.logger.warn("No provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + return end - securities_scope.each do |security| - sync_security_price(security: security, full_history: full_history) + required_exchange_rate_pairs.each do |pair| + # pair is a Hash with keys :source, :target, and :start_date + start_date = snapshot? ? default_start_date : pair[:start_date] + + ExchangeRate.sync_provider_rates( + from: pair[:source], + to: pair[:target], + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ) end end private - attr_reader :family, :account + attr_reader :mode, :clear_cache - def accounts_scope - return Account.where(id: account.id) if account - return family.accounts if family - Account.all + def snapshot? + mode.to_sym == :snapshot end - def entries_scope - account&.entries || family&.entries || Entry.all - end + # Builds a unique list of currency pairs with the earliest date we need + # exchange rates for. + # + # Returns: Array of Hashes – [{ source:, target:, start_date: }, ...] + def required_exchange_rate_pairs + pair_dates = {} # { [source, target] => earliest_date } - def securities_scope - if account - account.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) - elsif family - family.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil }) - else - Security.where.not(exchange_operating_mic: nil) + # 1. ENTRY-BASED PAIRS – we need rates from the first entry date + Entry.joins(:account) + .where.not("entries.currency = accounts.currency") + .group("entries.currency", "accounts.currency") + .minimum("entries.date") + .each do |(source, target), date| + key = [ source, target ] + pair_dates[key] = [ pair_dates[key], date ].compact.min + end + + # 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date + account_first_entry_dates = Entry.group(:account_id).minimum(:date) + + Account.joins(:family) + .where.not("families.currency = accounts.currency") + .select("accounts.id, accounts.currency AS source, families.currency AS target") + .find_each do |account| + earliest_entry_date = account_first_entry_dates[account.id] + + chosen_date = [ earliest_entry_date, default_start_date ].compact.min + + key = [ account.source, account.target ] + pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min + end + + # Convert to array of hashes for ease of use + pair_dates.map do |(source, target), date| + { source: source, target: target, start_date: date } end end - def sync_security_price(security:, full_history:) - start_date = full_history ? find_oldest_required_price(security: security) : default_start_date + def get_first_required_price_date(security) + return default_start_date if snapshot? - Rails.logger.info("Syncing security price for: #{security.ticker}, start_date: #{start_date}, end_date: #{end_date}") - - fetched_prices = price_provider.fetch_security_prices( - security, - start_date: start_date, - end_date: end_date - ) - - unless fetched_prices.success? - error = MissingSecurityPriceError.new( - "#{PRICE_PROVIDER_NAME} could not fetch security price for: #{security.ticker} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_prices.error.message}" - ) - - Rails.logger.warn(error.message) - Sentry.capture_exception(error, level: :warning) - - return - end - - prices_for_upsert = fetched_prices.data.map do |price| - if price.security.nil? || price.date.nil? || price.price.nil? || price.currency.nil? - error = InvalidSecurityPriceDataError.new( - "#{PRICE_PROVIDER_NAME} returned invalid price data for security: #{security.ticker} on: #{price.date}. Price data: #{price.inspect}" - ) - - Rails.logger.warn(error.message) - Sentry.capture_exception(error, level: :warning) - - next - end - - { - security_id: price.security.id, - date: price.date, - price: price.price, - currency: price.currency - } - end.compact - - Security::Price.upsert_all( - prices_for_upsert, - unique_by: %i[security_id date currency] - ) + Trade.with_entry.where(security: security).minimum(:date) end - def sync_exchange_rate(from:, to:, full_history:) - start_date = full_history ? find_oldest_required_rate(from_currency: from) : default_start_date + # An approximation that grabs more than we likely need, but simplifies the logic + def get_first_required_exchange_rate_date(from_currency:) + return default_start_date if snapshot? - Rails.logger.info("Syncing exchange rate from: #{from}, to: #{to}, start_date: #{start_date}, end_date: #{end_date}") - - fetched_rates = rate_provider.fetch_exchange_rates( - from: from, - to: to, - start_date: start_date, - end_date: end_date - ) - - unless fetched_rates.success? - message = "#{RATE_PROVIDER_NAME} could not fetch exchange rate pair from: #{from} to: #{to} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_rates.error.message}" - Rails.logger.warn(message) - Sentry.capture_exception(MissingExchangeRateError.new(message)) - return - end - - rates_for_upsert = fetched_rates.data.map do |rate| - if rate.from.nil? || rate.to.nil? || rate.date.nil? || rate.rate.nil? - message = "#{RATE_PROVIDER_NAME} returned invalid rate data for pair from: #{from} to: #{to} on: #{rate.date}. Rate data: #{rate.inspect}" - Rails.logger.warn(message) - Sentry.capture_exception(InvalidExchangeRateDataError.new(message)) - next - end - - { - from_currency: rate.from, - to_currency: rate.to, - date: rate.date, - rate: rate.rate - } - end.compact - - ExchangeRate.upsert_all( - rates_for_upsert, - unique_by: %i[from_currency to_currency date] - ) - end - - def rate_provider - Provider::Registry.for_concept(:exchange_rates).get_provider(RATE_PROVIDER_NAME) - end - - def price_provider - Provider::Registry.for_concept(:securities).get_provider(PRICE_PROVIDER_NAME) - end - - def find_oldest_required_rate(from_currency:) - entries_scope.where(currency: from_currency).minimum(:date) || default_start_date + Entry.where(currency: from_currency).minimum(:date) end def default_start_date - DEFAULT_HISTORY_DAYS.days.ago.to_date + SNAPSHOT_DAYS.days.ago.to_date end # Since we're querying market data from a US-based API, end date should always be today (EST) def end_date Date.current.in_time_zone("America/New_York").to_date end + + def set_mode!(mode) + valid_modes = [ :full, :snapshot ] + + unless valid_modes.include?(mode.to_sym) + raise InvalidModeError, "Invalid mode for MarketDataSyncer, can only be :full or :snapshot, but was #{mode}" + end + + mode.to_sym + end end diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index 1fc915e7..d54b2011 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -2,22 +2,22 @@ module Provider::SecurityConcept extend ActiveSupport::Concern Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) - SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind) - Price = Data.define(:security, :date, :price, :currency) + SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic) + Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic) def search_securities(symbol, country_code: nil, exchange_operating_mic: nil) raise NotImplementedError, "Subclasses must implement #search_securities" end - def fetch_security_info(security) + def fetch_security_info(symbol:, exchange_operating_mic:) raise NotImplementedError, "Subclasses must implement #fetch_security_info" end - def fetch_security_price(security, date:) + def fetch_security_price(symbol:, exchange_operating_mic:, date:) raise NotImplementedError, "Subclasses must implement #fetch_security_price" end - def fetch_security_prices(security, start_date:, end_date:) + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) raise NotImplementedError, "Subclasses must implement #fetch_security_prices" end end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index ff75ff49..fee3a236 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -3,6 +3,8 @@ class Provider::Synth < Provider # Subclass so errors caught in this provider are raised as Provider::Synth::Error Error = Class.new(Provider::Error) + InvalidExchangeRateError = Class.new(Error) + InvalidSecurityPriceError = Class.new(Error) def initialize(api_key) @api_key = api_key @@ -48,7 +50,7 @@ class Provider::Synth < Provider rates = JSON.parse(response.body).dig("data", "rates") - Rate.new(date:, from:, to:, rate: rates.dig(to)) + Rate.new(date: date.to_date, from:, to:, rate: rates.dig(to)) end end @@ -65,8 +67,18 @@ class Provider::Synth < Provider end data.paginated.map do |rate| - Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to)) - end + date = rate.dig("date") + rate = rate.dig("rates", to) + + if date.nil? || rate.nil? + message = "#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}" + Rails.logger.warn(message) + Sentry.capture_exception(InvalidExchangeRateError.new(message), level: :warning) + next + end + + Rate.new(date: date.to_date, from:, to:, rate:) + end.compact end end @@ -97,65 +109,73 @@ class Provider::Synth < Provider end end - def fetch_security_info(security) + def fetch_security_info(symbol:, exchange_operating_mic:) with_provider_response do - response = client.get("#{base_url}/tickers/#{security.ticker}") do |req| - req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present? - req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present? + response = client.get("#{base_url}/tickers/#{symbol}") do |req| + req.params["operating_mic"] = exchange_operating_mic end data = JSON.parse(response.body).dig("data") SecurityInfo.new( - symbol: data.dig("ticker"), + symbol: symbol, name: data.dig("name"), links: data.dig("links"), logo_url: data.dig("logo_url"), description: data.dig("description"), - kind: data.dig("kind") + kind: data.dig("kind"), + exchange_operating_mic: exchange_operating_mic ) end end - def fetch_security_price(security, date:) + def fetch_security_price(symbol:, exchange_operating_mic:, date:) with_provider_response do - historical_data = fetch_security_prices(security, start_date: date, end_date: date) + historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) - raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty? + raise ProviderError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.empty? historical_data.data.first end end - def fetch_security_prices(security, start_date:, end_date:) + def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) with_provider_response do params = { start_date: start_date, - end_date: end_date + end_date: end_date, + operating_mic_code: exchange_operating_mic } - params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present? - data = paginate( - "#{base_url}/tickers/#{security.ticker}/open-close", + "#{base_url}/tickers/#{symbol}/open-close", params ) do |body| body.dig("prices") end currency = data.first_page.dig("currency") - country_code = data.first_page.dig("exchange", "country_code") - exchange_mic = data.first_page.dig("exchange", "mic_code") exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code") data.paginated.map do |price| + date = price.dig("date") + price = price.dig("close") || price.dig("open") + + if date.nil? || price.nil? + message = "#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}" + Rails.logger.warn(message) + Sentry.capture_exception(InvalidSecurityPriceError.new(message), level: :warning) + next + end + Price.new( - security: security, - date: price.dig("date"), - price: price.dig("close") || price.dig("open"), - currency: currency + symbol: symbol, + date: date.to_date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic ) - end + end.compact end end diff --git a/app/models/security/price/syncer.rb b/app/models/security/price/syncer.rb new file mode 100644 index 00000000..dbdf0831 --- /dev/null +++ b/app/models/security/price/syncer.rb @@ -0,0 +1,145 @@ +class Security::Price::Syncer + MissingSecurityPriceError = Class.new(StandardError) + MissingStartPriceError = Class.new(StandardError) + + def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false) + @security = security + @security_provider = security_provider + @start_date = start_date + @end_date = normalize_end_date(end_date) + @clear_cache = clear_cache + end + + # Constructs a daily series of prices for a single security over the date range. + # Returns the number of rows upserted. + def sync_provider_prices + if !clear_cache && all_prices_exist? + Rails.logger.info("No new prices to sync for #{security.ticker} between #{start_date} and #{end_date}, skipping") + return 0 + end + + if clear_cache && provider_prices.empty? + Rails.logger.warn("Could not clear cache for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices") + return 0 + end + + prev_price_value = start_price_value + + unless prev_price_value.present? + error = MissingStartPriceError.new("Could not find a start price for #{security.ticker} on or before #{start_date}") + Rails.logger.error(error.message) + Sentry.capture_exception(error) + return 0 + end + + gapfilled_prices = effective_start_date.upto(end_date).map do |date| + db_price_value = db_prices[date]&.price + provider_price_value = provider_prices[date]&.price + provider_currency = provider_prices[date]&.currency + + chosen_price = if clear_cache + provider_price_value || db_price_value # overwrite when possible + else + db_price_value || provider_price_value # fill gaps + end + + # Gap-fill using LOCF (last observation carried forward) + chosen_price ||= prev_price_value + prev_price_value = chosen_price + + { + security_id: security.id, + date: date, + price: chosen_price, + currency: provider_currency || prev_price_currency || db_price_currency || "USD" + } + end + + upsert_rows(gapfilled_prices) + end + + private + attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache + + def provider_prices + @provider_prices ||= begin + provider_fetch_start_date = effective_start_date - 5.days + + response = security_provider.fetch_security_prices( + symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: provider_fetch_start_date, + end_date: end_date + ) + + if response.success? + response.data.index_by(&:date) + else + msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}" + Rails.logger.warn(msg) + Sentry.capture_exception(MissingSecurityPriceError.new(msg)) + {} + end + end + end + + def db_prices + @db_prices ||= Security::Price.where(security_id: security.id, date: start_date..end_date) + .order(:date) + .to_a + .index_by(&:date) + end + + def all_prices_exist? + db_prices.count == expected_count + end + + def expected_count + (start_date..end_date).count + end + + # Skip over ranges that already exist unless clearing cache + def effective_start_date + return start_date if clear_cache + + (start_date..end_date).detect { |d| !db_prices.key?(d) } || end_date + end + + def start_price_value + provider_price_value = provider_prices.select { |date, _| date <= start_date } + .max_by { |date, _| date } + &.last&.price + db_price_value = db_prices[start_date]&.price + provider_price_value || db_price_value + end + + def upsert_rows(rows) + batch_size = 200 + total_upsert_count = 0 + + rows.each_slice(batch_size) do |batch| + ids = Security::Price.upsert_all( + batch, + unique_by: %i[security_id date currency], + returning: [ "id" ] + ) + total_upsert_count += ids.count + end + + total_upsert_count + end + + def db_price_currency + db_prices.values.first&.currency + end + + def prev_price_currency + @prev_price_currency ||= provider_prices.values.first&.currency + end + + # Clamp to today (EST) so we never call our price API for a future date (our API is in EST/EDT timezone) + def normalize_end_date(requested_end_date) + today_est = Date.current.in_time_zone("America/New_York").to_date + [ requested_end_date, today_est ].min + end +end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 3d344f29..2214ccfa 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -49,6 +49,48 @@ module Security::Provided price end + def sync_provider_details(clear_cache: false) + unless provider.present? + Rails.logger.warn("No provider configured for Security.sync_provider_details") + return + end + + if self.name.present? && self.logo_url.present? && !clear_cache + return + end + + response = provider.fetch_security_info( + symbol: ticker, + exchange_operating_mic: exchange_operating_mic + ) + + if response.success? + update( + name: response.data.name, + logo_url: response.data.logo_url, + ) + else + err = StandardError.new("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") + Rails.logger.warn(err.message) + Sentry.capture_exception(err, level: :warning) + end + end + + def sync_provider_prices(start_date:, end_date:, clear_cache: false) + unless provider.present? + Rails.logger.warn("No provider configured for Security.sync_provider_prices") + return 0 + end + + Security::Price::Syncer.new( + security: self, + security_provider: provider, + start_date: start_date, + end_date: end_date, + clear_cache: clear_cache + ).sync_provider_prices + end + private def provider self.class.provider diff --git a/app/models/stock_exchange.rb b/app/models/stock_exchange.rb deleted file mode 100644 index 93631426..00000000 --- a/app/models/stock_exchange.rb +++ /dev/null @@ -1,3 +0,0 @@ -class StockExchange < ApplicationRecord - scope :in_country, ->(country_code) { where(country_code: country_code) } -end diff --git a/app/models/trade_builder.rb b/app/models/trade_builder.rb index 5a2f9df1..cf9800e5 100644 --- a/app/models/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -129,13 +129,9 @@ class TradeBuilder def security ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] - security = Security.find_or_create_by!( + Security.find_or_create_by!( ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic ) - - FetchSecurityInfoJob.perform_later(security.id) - - security end end diff --git a/config/exchanges.yml b/config/exchanges.yml deleted file mode 100644 index 9b429d14..00000000 --- a/config/exchanges.yml +++ /dev/null @@ -1,1020 +0,0 @@ -- name: NASDAQ Stock Exchange - acronym: NASDAQ - mic: XNAS - country: USA - country_code: US - city: New York - website: www.nasdaq.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: New York Stock Exchange - acronym: NYSE - mic: XNYS - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: NYSE ARCA - acronym: NYSEARCA - mic: ARCX - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Markets - acronym: - mic: OTCM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Buenos Aires Stock Exchange - acronym: BCBA - mic: XBUE - country: Argentina - country_code: AR - city: Buenos Aires - website: www.bcba.sba.com.ar - timezone: - timezone: America/Argentina/Buenos_Aires - abbr: -03 - abbr_dst: -03 - currency: - code: ARS - symbol: AR$ - name: Argentine Peso -- name: Bahrein Bourse - acronym: BSE - mic: XBAH - country: Bahrain - country_code: BH - city: Manama - website: www.bahrainbourse.com.bh - timezone: - timezone: Asia/Bahrain - abbr: +03 - abbr_dst: +03 - currency: - code: BHD - symbol: BD - name: Bahraini Dinar -- name: Euronext Brussels - acronym: Euronext - mic: XBRU - country: Belgium - country_code: BE - city: Brussels - website: www.euronext.com - timezone: - timezone: Europe/Brussels - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: B3 - Brasil Bolsa Balcão S.A - acronym: Bovespa - mic: BVMF - country: Brazil - country_code: BR - city: Sao Paulo - website: www.bmfbovespa.com.br - timezone: - timezone: America/Sao_Paulo - abbr: -03 - abbr_dst: -03 - currency: - code: BRL - symbol: R$ - name: Brazilian Real -- name: Toronto Stock Exchange - acronym: TSX - mic: XTSE - country: Canada - country_code: CA - city: Toronto - website: www.tse.com - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Canadian Securities Exchange - acronym: CNSX - mic: XCNQ - country: Canada - country_code: CA - city: Toronto - website: www.cnsx.ca - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Santiago Stock Exchange - acronym: BVS - mic: XSGO - country: Chile - country_code: CL - city: Santiago - website: www.bolsadesantiago.com - timezone: - timezone: America/Santiago - abbr: -03 - abbr_dst: -04 - currency: - code: CLP - symbol: CL$ - name: Chilean Peso -- name: Shanghai Stock Exchange - acronym: SSE - mic: XSHG - country: China - country_code: CN - city: Shanghai - website: www.sse.com.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Shenzhen Stock Exchange - acronym: SZSE - mic: XSHE - country: China - country_code: CN - city: Shenzhen - website: www.szse.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Bolsa de Valores de Colombia - acronym: BVC - mic: XBOG - country: Colombia - country_code: CO - city: Bogota - website: www.bvc.com.co - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: COP - symbol: CO$ - name: Colombian Peso -- name: Copenhagen Stock Exchange - acronym: OMXC - mic: XCSE - country: Denmark - country_code: DK - city: Copenhagen - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Copenhagen - abbr: CET - abbr_dst: CEST - currency: - code: DKK - symbol: Dkr - name: Danish Krone -- name: Eqyptian Exchange - acronym: EGX - mic: XCAI - country: Egypt - country_code: EG - city: Cairo - website: www.egyptse.com - timezone: - timezone: Africa/Cairo - abbr: EET - abbr_dst: EET - currency: - code: EGP - symbol: EGP - name: Egyptian Pound -- name: Tallinn Stock Exchange - acronym: OMXT - mic: XTAL - country: Estonia - country_code: EE - city: Tallinn - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Tallinn - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Helsinki Stock Exchange - acronym: OMXH - mic: XHEL - country: Finland - country_code: FI - city: Helsinki - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Helsinki - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Paris - acronym: Euronext - mic: XPAR - country: France - country_code: FR - city: Paris - website: www.euronext.com - timezone: - timezone: Europe/Paris - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse - acronym: FSX - mic: XFRA - country: Germany - country_code: DE - city: Frankfurt - website: www.deutsche-boerse.com - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Börse Stuttgart - acronym: XSTU - mic: XSTU - country: Germany - country_code: DE - city: Stuttgart - website: www.boerse-stuttgart.de - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse Xetra - acronym: XETR - mic: XETRA - country: Germany - country_code: DE - city: Frankfurt - website: - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Hong Kong Stock Exchange - acronym: HKEX - mic: XHKG - country: Hong Kong - country_code: HK - city: Hong Kong - website: www.hkex.com.hk - timezone: - timezone: Asia/Hong_Kong - abbr: HKT - abbr_dst: HKT - currency: - code: HKD - symbol: HK$ - name: Hong Kong Dollar -- name: Nasdaq Island - acronym: XICE - mic: XICE - country: Iceland - country_code: IS - city: Reykjavík - website: www.nasdaqomxnordic.com - timezone: - timezone: Atlantic/Reykjavik - abbr: GMT - abbr_dst: GMT - currency: - code: ISK - symbol: Ikr - name: Icelandic Króna -- name: Bombay Stock Exchange - acronym: MSE - mic: XBOM - country: India - country_code: IN - city: Mumbai - website: www.bseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: National Stock Exchange India - acronym: NSE - mic: XNSE - country: India - country_code: IN - city: Mumbai - website: www.nseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: Jakarta Stock Exchange - acronym: IDX - mic: XIDX - country: Indonesia - country_code: ID - city: Jakarta - website: www.idx.co.id - timezone: - timezone: Asia/Jakarta - abbr: WIB - abbr_dst: WIB - currency: - code: IDR - symbol: Rp - name: Indonesian Rupiah -- name: Tel Aviv Stock Exchange - acronym: TASE - mic: XTAE - country: Israel - country_code: IL - city: Tel Aviv - website: www.tase.co.il - timezone: - timezone: Asia/Jerusalem - abbr: IST - abbr_dst: IDT - currency: - code: ILS - symbol: ₪ - name: Israeli New Sheqel -- name: Borsa Italiana - acronym: MIL - mic: XMIL - country: Italy - country_code: IT - city: Milano - website: www.borsaitaliana.it - timezone: - timezone: Europe/Rome - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nagoya Stock Exchange - acronym: NSE - mic: XNGO - country: Japan - country_code: JP - city: Nagoya - website: www.nse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Fukuoka Stock Exchange - acronym: XFKA - mic: XFKA - country: Japan - country_code: JP - city: Fukuoka - website: www.fse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Sapporo Stock Exchange - acronym: XSAP - mic: XSAP - country: Japan - country_code: JP - city: Sapporo - website: www.sse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Nasdaq Riga - acronym: OMXR - mic: XRIS - country: Latvia - country_code: LV - city: Riga - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Riga - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nasdaq Vilnius - acronym: OMXV - mic: XLIT - country: Lithuania - country_code: LT - city: Vilnius - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Vilnius - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Malaysia Stock Exchange - acronym: MYX - mic: XKLS - country: Malaysia - country_code: MY - city: Kuala Lumpur - website: www.bursamalaysia.com - timezone: - timezone: Asia/Kuala_Lumpur - abbr: +08 - abbr_dst: +08 - currency: - code: MYR - symbol: RM - name: Malaysian Ringgit -- name: Mexican Stock Exchange - acronym: BMV - mic: XMEX - country: Mexico - country_code: MX - city: Mexico City - website: www.bmv.com.mx - timezone: - timezone: America/Mexico_City - abbr: CST - abbr_dst: CDT - currency: - code: MXN - symbol: MX$ - name: Mexican Peso -- name: Euronext Amsterdam - acronym: Euronext - mic: XAMS - country: Netherlands - country_code: NL - city: Amsterdam - website: www.euronext.com - timezone: - timezone: Europe/Amsterdam - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: New Zealand Stock Exchange - acronym: NZX - mic: XNZE - country: New Zealand - country_code: NZ - city: Wellington - website: www.nzx.com - timezone: - timezone: Pacific/Auckland - abbr: NZDT - abbr_dst: NZST - currency: - code: NZD - symbol: NZ$ - name: New Zealand Dollar -- name: Nigerian Stock Exchange - acronym: NSE - mic: XNSA - country: Nigeria - country_code: NG - city: Lagos - website: www.nse.com.ng - timezone: - timezone: Africa/Lagos - abbr: WAT - abbr_dst: WAT - currency: - code: NGN - symbol: ₦ - name: Nigerian Naira -- name: Oslo Stock Exchange - acronym: OSE - mic: XOSL - country: Norway - country_code: NO - city: Oslo - website: www.oslobors.no - timezone: - timezone: Europe/Oslo - abbr: CET - abbr_dst: CEST - currency: - code: NOK - symbol: Nkr - name: Norwegian Krone -- name: Bolsa de Valores de Lima - acronym: BVL - mic: XLIM - country: Peru - country_code: PE - city: Lima - website: www.bvl.com.pe - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: PEN - symbol: S/. - name: Peruvian Nuevo Sol -- name: Warsaw Stock Exchange - acronym: GPW - mic: XWAR - country: Poland - country_code: PL - city: Warsaw - website: www.gpw.pl - timezone: - timezone: Europe/Warsaw - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Lisbon - acronym: Euronext - mic: XLIS - country: Portugal - country_code: PT - city: Lisboa - website: www.euronext.com - timezone: - timezone: Europe/Lisbon - abbr: WET - abbr_dst: WEST - currency: - code: EUR - symbol: € - name: Euro -- name: Qatar Stock Exchange - acronym: QE - mic: DSMD - country: Qatar - country_code: QA - city: Doha - website: www.qatarexchange.qa - timezone: - timezone: Asia/Qatar - abbr: +03 - abbr_dst: +03 - currency: - code: QAR - symbol: QR - name: Qatari Rial -- name: Moscow Stock Exchange - acronym: MOEX - mic: MISX - country: Russia - country_code: RU - city: Moscow - website: www.moex.com - timezone: - timezone: Europe/Moscow - abbr: MSK - abbr_dst: MSK - currency: - code: RUB - symbol: RUB - name: Russian Ruble -- name: Saudi Stock Exchange - acronym: TADAWUL - mic: XSAU - country: Saudi Arabia - country_code: SA - city: Riyadh - website: www.tadawul.com.sa - timezone: - timezone: Asia/Riyadh - abbr: +03 - abbr_dst: +03 - currency: - code: SAR - symbol: SR - name: Saudi Riyal -- name: Belgrade Stock Exchange - acronym: BELEX - mic: XBEL - country: Serbia - country_code: RS - city: Belgrade - website: www.belex.rs - timezone: - timezone: Europe/Belgrade - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Singapore Stock Exchange - acronym: SGX - mic: XSES - country: Singapore - country_code: SG - city: Singapore - website: www.sgx.com - timezone: - timezone: Asia/Singapore - abbr: +08 - abbr_dst: +08 - currency: - code: SGD - symbol: S$ - name: Singapore Dollar -- name: Johannesburg Stock Exchange - acronym: JSE - mic: XJSE - country: South Africa - country_code: ZA - city: Johannesburg - website: www.jse.co.za - timezone: - timezone: Africa/Johannesburg - abbr: SAST - abbr_dst: SAST - currency: - code: ZAR - symbol: R - name: South African Rand -- name: Korean Stock Exchange - acronym: KRX - mic: XKRX - country: South Korea - country_code: KR - city: Seoul - website: http://eng.krx.co.kr - timezone: - timezone: Asia/Seoul - abbr: KST - abbr_dst: KST - currency: - code: KRW - symbol: ₩ - name: South Korean Won -- name: Bolsas y Mercados Españoles - acronym: BME - mic: BMEX - country: Spain - country_code: ES - city: Madrid - website: www.bolsasymercados.es - timezone: - timezone: Europe/Madrid - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Stockholm Stock Exchange - acronym: OMX - mic: XSTO - country: Sweden - country_code: SE - city: Stockholm - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Stockholm - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: SIX Swiss Exchange - acronym: SIX - mic: XSWX - country: Switzerland - country_code: CH - city: Zurich - website: www.six-swiss-exchange.com - timezone: - timezone: Europe/Zurich - abbr: CET - abbr_dst: CEST - currency: - code: CHF - symbol: CHF - name: Swiss Franc -- name: Taiwan Stock Exchange - acronym: TWSE - mic: XTAI - country: Taiwan - country_code: TW - city: Taipei - website: www.twse.com.tw/en/ - timezone: - timezone: Asia/Taipei - abbr: CST - abbr_dst: CST - currency: - code: TWD - symbol: NT$ - name: New Taiwan Dollar -- name: Stock Exchange of Thailand - acronym: SET - mic: XBKK - country: Thailand - country_code: TH - city: Bangkok - website: www.set.or.th - timezone: - timezone: Asia/Bangkok - abbr: +07 - abbr_dst: +07 - currency: - code: THB - symbol: ฿ - name: Thai Baht -- name: Istanbul Stock Exchange - acronym: BIST - mic: XIST - country: Turkey - country_code: TR - city: Istanbul - website: www.borsaistanbul.com - timezone: - timezone: Europe/Istanbul - abbr: +03 - abbr_dst: +03 - currency: - code: TRY - symbol: TL - name: Turkish Lira -- name: Dubai Financial Market - acronym: DFM - mic: XDFM - country: United Arab Emirates - country_code: AE - city: Dubai - website: www.dfm.co.ae - timezone: - timezone: Asia/Dubai - abbr: +04 - abbr_dst: +04 - currency: - code: AED - symbol: AED - name: United Arab Emirates Dirham -- name: London Stock Exchange - acronym: LSE - mic: XLON - country: United Kingdom - country_code: GB - city: London - website: www.londonstockexchange.com - timezone: - timezone: Europe/London - abbr: GMT - abbr_dst: BST - currency: - code: GBP - symbol: £ - name: British Pound Sterling -- name: Ho Chi Minh Stock Exchange - acronym: HOSE - mic: XSTC - country: Vietnam - country_code: VN - city: Ho Chi Minh City - website: www.hsx.vn - timezone: - timezone: Asia/Ho_Chi_Minh - abbr: +07 - abbr_dst: +07 - currency: - code: VND - symbol: ₫ - name: Vietnamese Dong -- name: American Stock Exchange - acronym: AMEX - mic: XASE - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Cboe BZX U.S. Equities Exchang - acronym: BATS - mic: XCBO - country: USA - country_code: US - city: Chicago - website: markets.cboe.com - timezone: - timezone: America/Chicago - abbr: CDT - abbr_dst: CDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: US Mutual Funds - acronym: NMFQS - mic: NMFQS - country: USA - country_code: US - city: New York - website: - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Bulletin Board - acronym: OTCBB - mic: OOTC - country: USA - country_code: US - city: Washington - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Grey Market - acronym: OTCGREY - mic: PSGM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQB Marketplace - acronym: OTCQB - mic: OTCB - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQX Marketplace - acronym: OTCQX - mic: OTCQ - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC PINK current - acronym: PINK - mic: PINC - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Investors Exchange - acronym: IEX - mic: IEXG - country: USA - country_code: US - city: New York - website: www.iextrading.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar \ No newline at end of file diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9040f864..70a6e476 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,11 +1,13 @@ require "sidekiq/web" -Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe")) +if Rails.env.production? + Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| + configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe")) + configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe")) - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + end end Sidekiq::Cron.configure do |config| diff --git a/config/schedule.yml b/config/schedule.yml index 561e2327..8eb8ef0a 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -1,5 +1,8 @@ sync_market_data: - cron: "0 17 * * 1-5" # 5:00 PM EST (1 hour after market close) + cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) class: "SyncMarketDataJob" queue: "scheduled" description: "Syncs market data daily at 5:00 PM EST (1 hour after market close)" + args: + mode: "full" + clear_cache: false diff --git a/db/migrate/20250516180846_remove_stock_exchanges.rb b/db/migrate/20250516180846_remove_stock_exchanges.rb new file mode 100644 index 00000000..19c4a529 --- /dev/null +++ b/db/migrate/20250516180846_remove_stock_exchanges.rb @@ -0,0 +1,11 @@ +class RemoveStockExchanges < ActiveRecord::Migration[7.2] + def change + drop_table :stock_exchanges do |t| + t.string :name, null: false + t.string :acronym + t.string :mic, null: false + t.string :country, null: false + t.string :country_code, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f25cb20..5b9426c7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_05_14_214242) do +ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -550,27 +550,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_14_214242) do t.index ["var"], name: "index_settings_on_var", unique: true end - create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "acronym" - t.string "mic", null: false - t.string "country", null: false - t.string "country_code", null: false - t.string "city" - t.string "website" - t.string "timezone_name" - t.string "timezone_abbr" - t.string "timezone_abbr_dst" - t.string "currency_code" - t.string "currency_symbol" - t.string "currency_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["country"], name: "index_stock_exchanges_on_country" - t.index ["country_code"], name: "index_stock_exchanges_on_country_code" - t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" - end - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "status", null: false diff --git a/test/fixtures/stock_exchanges.yml b/test/fixtures/stock_exchanges.yml deleted file mode 100644 index 872e5b27..00000000 --- a/test/fixtures/stock_exchanges.yml +++ /dev/null @@ -1,13 +0,0 @@ -nasdaq: - name: NASDAQ - mic: XNAS - acronym: NASDAQ - country: USA - country_code: US - -nyse: - name: New York Stock Exchange - mic: XNYS - acronym: NYSE - country: USA - country_code: US diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index 9293c4d9..3716f6fc 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -15,6 +15,7 @@ module ExchangeRateProviderInterfaceTest assert_equal "USD", rate.from assert_equal "GBP", rate.to + assert rate.date.is_a?(Date) assert_in_delta 0.78, rate.rate, 0.01 end end @@ -26,6 +27,7 @@ module ExchangeRateProviderInterfaceTest ) assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024 + assert response.data.first.date.is_a?(Date) end end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb index 44385ede..a994bb73 100644 --- a/test/interfaces/security_provider_interface_test.rb +++ b/test/interfaces/security_provider_interface_test.rb @@ -7,7 +7,7 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_price") do - response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + response = @subject.fetch_security_price(symbol: aapl.ticker, exchange_operating_mic: aapl.exchange_operating_mic, date: Date.iso8601("2024-08-01")) assert response.success? assert response.data.present? @@ -19,12 +19,14 @@ module SecurityProviderInterfaceTest VCR.use_cassette("#{vcr_key_prefix}/security_prices") do response = @subject.fetch_security_prices( - aapl, + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic, start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01") ) assert response.success? + assert response.data.first.date.is_a?(Date) assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213 end end @@ -44,7 +46,11 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_info") do - response = @subject.fetch_security_info(aapl) + response = @subject.fetch_security_info( + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic + ) + info = response.data assert_equal "AAPL", info.symbol diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb deleted file mode 100644 index 8d77cae5..00000000 --- a/test/models/account/convertible_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require "test_helper" -require "ostruct" - -class Account::ConvertibleTest < ActiveSupport::TestCase - include EntriesTestHelper, ProviderTestHelper - - setup do - @family = families(:empty) - @family.update!(currency: "USD") - - # Foreign account (currency is not in the family's primary currency, so it will require exchange rates for net worth rollups) - @account = @family.accounts.create!(name: "Test Account", currency: "EUR", balance: 10000, accountable: Depository.new) - - @provider = mock - ExchangeRate.stubs(:provider).returns(@provider) - end - - test "syncs required exchange rates for an account" do - create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR") - - # Since we had a valuation 1 day ago, this account starts 2 days ago and needs daily exchange rates looking forward - assert_equal 2.days.ago.to_date, @account.start_date - - ExchangeRate.delete_all - - provider_response = provider_success_response( - [ - OpenStruct.new(from: "EUR", to: "USD", date: 2.days.ago.to_date, rate: 1.1), - OpenStruct.new(from: "EUR", to: "USD", date: 1.day.ago.to_date, rate: 1.2), - OpenStruct.new(from: "EUR", to: "USD", date: Date.current, rate: 1.3) - ] - ) - - @provider.expects(:fetch_exchange_rates) - .with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - assert_difference "ExchangeRate.count", 3 do - @account.sync_required_exchange_rates - end - end - - test "does not sync rates for a domestic account" do - @account.update!(currency: "USD") - - @provider.expects(:fetch_exchange_rates).never - - assert_no_difference "ExchangeRate.count" do - @account.sync_required_exchange_rates - end - end -end diff --git a/test/models/account/market_data_syncer_test.rb b/test/models/account/market_data_syncer_test.rb new file mode 100644 index 00000000..596798f5 --- /dev/null +++ b/test/models/account/market_data_syncer_test.rb @@ -0,0 +1,107 @@ +require "test_helper" +require "ostruct" + +class Account::MarketDataSyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + PROVIDER_BUFFER = 5.days + + setup do + # Ensure a clean slate for deterministic assertions + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all + Entry.delete_all + + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) + end + + test "syncs required exchange rates for a foreign-currency account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new + ) + + # Seed a rate for the first required day so that the syncer only needs the next day forward + existing_date = account.start_date + ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0) + + expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: existing_date, rate: 1.5) + ])) + + before = ExchangeRate.count + Account::MarketDataSyncer.new(account).sync_market_data + after = ExchangeRate.count + + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" + end + + test "syncs security prices for securities traded by the account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Brokerage", + currency: "USD", + balance: 0, + accountable: Investment.new + ) + + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + trade_date = 10.days.ago.to_date + trade = Trade.new(security: security, qty: 1, price: 100, currency: "USD") + + account.entries.create!( + name: "Buy AAPL", + date: trade_date, + amount: 100, + currency: "USD", + entryable: trade + ) + + expected_start_date = trade_date - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: trade_date, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic) + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange-rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + Account::MarketDataSyncer.new(account).sync_market_data + + assert_equal 1, Security::Price.where(security: security, date: trade_date).count + end +end diff --git a/test/models/exchange_rate/syncer_test.rb b/test/models/exchange_rate/syncer_test.rb new file mode 100644 index 00000000..58818834 --- /dev/null +++ b/test/models/exchange_rate/syncer_test.rb @@ -0,0 +1,148 @@ +require "test_helper" +require "ostruct" + +class ExchangeRate::SyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + end + + test "syncs missing rates from provider" do + ExchangeRate.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date..Date.current) + .order(:date) + + assert_equal 3, db_rates.count + assert_equal 1.3, db_rates[0].rate + assert_equal 1.4, db_rates[1].rate + assert_equal 1.5, db_rates[2].rate + end + + test "syncs diff when some rates already exist" do + ExchangeRate.delete_all + + # Pre-populate DB with the first two days + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 3.days.ago.to_date, rate: 1.2) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.25) + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.3) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + + db_rates = ExchangeRate.order(:date) + assert_equal 4, db_rates.count + assert_equal [ 1.2, 1.25, 1.3, 1.3 ], db_rates.map(&:rate) + end + + test "no provider calls when all rates exist" do + ExchangeRate.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.2 + idx * 0.01) + end + + @provider.expects(:fetch_exchange_rates).never + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_rates + end + + # A helpful "reset" option for when we need to refresh provider data + test "full upsert if clear_cache is true" do + ExchangeRate.delete_all + + # Seed DB with stale data + (2.days.ago.to_date..Date.current).each do |date| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.0) + end + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).sync_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) + assert_equal [ 1.3, 1.4, 1.5 ], db_rates.map(&:rate) + end + + test "clamps end_date to today when future date is provided" do + ExchangeRate.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.6) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Syncer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: Date.current, + end_date: future_date + ).sync_provider_rates + + assert_equal 1, ExchangeRate.count + end + + private + def get_provider_fetch_start_date(start_date) + # We fetch with a 5 day buffer to account for weekends and holidays + start_date - 5.days + end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 64fc328b..021b4edf 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -67,26 +67,4 @@ class ExchangeRateTest < ActiveSupport::TestCase assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true) end - - test "upserts rates for currency pair and date range" do - ExchangeRate.delete_all - - ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) - - provider_response = provider_success_response([ - OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.3), - OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), - OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.5) - ]) - - @provider.expects(:fetch_exchange_rates) - .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - ExchangeRate.sync_provider_rates(from: "USD", to: "EUR", start_date: 2.days.ago.to_date) - - assert_equal 1.3, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate - assert_equal 1.4, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date).rate - assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date).rate - end end diff --git a/test/models/market_data_syncer_test.rb b/test/models/market_data_syncer_test.rb index 299fb82e..8a9db1f5 100644 --- a/test/models/market_data_syncer_test.rb +++ b/test/models/market_data_syncer_test.rb @@ -2,70 +2,84 @@ require "test_helper" require "ostruct" class MarketDataSyncerTest < ActiveSupport::TestCase - include EntriesTestHelper, ProviderTestHelper + include ProviderTestHelper - test "syncs exchange rates with upsert" do - empty_db + SNAPSHOT_START_DATE = MarketDataSyncer::SNAPSHOT_DAYS.days.ago.to_date + PROVIDER_BUFFER = 5.days - family1 = Family.create!(name: "Family 1", currency: "USD") - account1 = family1.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Depository.new) - account2 = family1.accounts.create!(name: "Account 2", currency: "CAD", balance: 100, accountable: Depository.new) + setup do + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all - family2 = Family.create!(name: "Family 2", currency: "EUR") - account3 = family2.accounts.create!(name: "Account 3", currency: "EUR", balance: 100, accountable: Depository.new) - account4 = family2.accounts.create!(name: "Account 4", currency: "USD", balance: 100, accountable: Depository.new) - - mock_provider = mock - Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once - - start_date = 1.month.ago.to_date - end_date = Date.current.in_time_zone("America/New_York").to_date - - # Put an existing rate in DB to test upsert - ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: start_date, rate: 2.0) - - mock_provider.expects(:fetch_exchange_rates) - .with(from: "CAD", to: "USD", start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(from: "CAD", to: "USD", date: start_date, rate: 1.0) ])) - - mock_provider.expects(:fetch_exchange_rates) - .with(from: "USD", to: "EUR", start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(from: "USD", to: "EUR", date: start_date, rate: 1.0) ])) - - assert_difference "ExchangeRate.count", 1 do - MarketDataSyncer.new.sync_exchange_rates - end - - assert_equal 1.0, ExchangeRate.where(from_currency: "CAD", to_currency: "USD", date: start_date).first.rate + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) end - test "syncs security prices with upsert" do - empty_db + test "syncs required exchange rates" do + family = Family.create!(name: "Smith", currency: "USD") + family.accounts.create!(name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new) - aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + # Seed stale rate so only the next missing day is fetched + ExchangeRate.create!(from_currency: "CAD", + to_currency: "USD", + date: SNAPSHOT_START_DATE, + rate: 2.0) - family = Family.create!(name: "Family 1", currency: "USD") - account = family.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Investment.new) + expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date - mock_provider = mock - Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: SNAPSHOT_START_DATE, rate: 1.5) + ])) - start_date = 1.month.ago.to_date - end_date = Date.current.in_time_zone("America/New_York").to_date + before = ExchangeRate.count + MarketDataSyncer.new(mode: :snapshot).sync_exchange_rates + after = ExchangeRate.count - mock_provider.expects(:fetch_security_prices) - .with(aapl, start_date: start_date, end_date: end_date) - .returns(provider_success_response([ OpenStruct.new(security: aapl, date: start_date, price: 100, currency: "USD") ])) - - assert_difference "Security::Price.count", 1 do - MarketDataSyncer.new.sync_prices - end + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" end - private - def empty_db - Invitation.destroy_all - Family.destroy_all - Security.destroy_all - end + test "syncs security prices" do + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: SNAPSHOT_START_DATE, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: "AAPL", exchange_operating_mic: "XNAS") + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + MarketDataSyncer.new(mode: :snapshot).sync_prices + + assert_equal 1, Security::Price.where(security: security, date: SNAPSHOT_START_DATE).count + end end diff --git a/test/models/security/price/syncer_test.rb b/test/models/security/price/syncer_test.rb new file mode 100644 index 00000000..25a3f14c --- /dev/null +++ b/test/models/security/price/syncer_test.rb @@ -0,0 +1,143 @@ +require "test_helper" +require "ostruct" + +class Security::Price::SyncerTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + @security = Security.create!(ticker: "AAPL") + end + + test "syncs missing prices from provider" do + Security::Price.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security, date: 2.days.ago.to_date..Date.current).order(:date) + + assert_equal 3, db_prices.count + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "syncs diff when some prices already exist" do + Security::Price.delete_all + + # Pre-populate DB with first two days + Security::Price.create!(security: @security, date: 3.days.ago.to_date, price: 140, currency: "USD") + Security::Price.create!(security: @security, date: 2.days.ago.to_date, price: 145, currency: "USD") + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal 4, db_prices.count + assert_equal [ 140, 145, 150, 150 ], db_prices.map(&:price) + end + + test "no provider calls when all prices exist" do + Security::Price.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + Security::Price.create!(security: @security, date:, price: 100 + idx, currency: "USD") + end + + @provider.expects(:fetch_security_prices).never + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).sync_provider_prices + end + + test "full upsert if clear_cache is true" do + Security::Price.delete_all + + # Seed DB with stale prices + (2.days.ago.to_date..Date.current).each do |date| + Security::Price.create!(security: @security, date:, price: 100, currency: "USD") + end + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).sync_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "clamps end_date to today when future date is provided" do + Security::Price.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: Date.current, price: 165, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + Security::Price::Syncer.new( + security: @security, + security_provider: @provider, + start_date: Date.current, + end_date: future_date + ).sync_provider_prices + + assert_equal 1, Security::Price.count + end + + private + def get_provider_fetch_start_date(start_date) + start_date - 5.days + end +end diff --git a/test/vcr_cassettes/synth/exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml index be1fa77a..adc22263 100644 --- a/test/vcr_cassettes/synth/exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:46 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"b0b21c870fe53492404cc5ac258fa465" + - W/"0c93a67d0c68e6f206e2954a41aa2933" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 44367fcb-e5b4-457d + - 146e30b2-e03b-47e3 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 8ce9dc85-afbd-437c-b18d-ec788b712334 + - 3cf7ade1-8066-422a-97c7-5f8b99e24296 X-Runtime: - - '0.031963' + - '0.024284' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=SwRPS1vBsrKtk%2Ftb7Ix8j%2FCWYw9tZgbJxR1FCmotWn%2FIZAE3Ri%2FUwHtvkOSqBq6HN5pLVetfem5hp%2BkqWmD5GRCVho0mp3VgRr3J1tBMwrVK2p50tfpmb3X22Jj%2BOfapq1C22PnN"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ih8sEFqAOWyINqAEtKGKPKO2lr1qAYSVeipyB5F8g2umPODXvCD4hN3G6wTTs2Q7H8CDWsqiOlYkmVvmr%2BWvl2ojOtBwO25Ahk9TbhlcgRO9nT6mEIXOSdVXJpzpRn5Ov%2FMGigpQ"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f6378fe582237-ORD + - 940b109b5df1a3d7-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26670&min_rtt=26569&rtt_var=10167&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=922&delivery_rate=105759&cwnd=181&unsent_bytes=0&cid=f0a872e0b2909c59&ts=188&x=0" + - cfL4;desc="?proto=TCP&rtt=25865&min_rtt=25683&rtt_var=9996&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=922&delivery_rate=106690&cwnd=219&unsent_bytes=0&cid=e48ae188d1f86721&ts=190&x=0" body: encoding: ASCII-8BIT - string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249830,"date":"2024-01-01"}}' - recorded_at: Sat, 15 Mar 2025 22:18:46 GMT + string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249734,"date":"2024-01-01"}}' + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/exchange_rates.yml b/test/vcr_cassettes/synth/exchange_rates.yml index ffb7b69a..87071b8f 100644 --- a/test/vcr_cassettes/synth/exchange_rates.yml +++ b/test/vcr_cassettes/synth/exchange_rates.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 21:48:33 GMT + - Fri, 16 May 2025 13:01:35 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"8081859271e9ca46ee021f706a0cc683" + - W/"ad21b1fba71fe0b149fe37b483a60438" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 6d036078-7f2f-4037 + - 28bc6622-47b8-4aeb Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 9ec8d111-aa67-4fb9-8885-7de64e1b1219 + - fcf251a3-f850-4464-9592-ced9de5e0c86 X-Runtime: - - '0.025769' + - '0.080857' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=3PGbjN13Yz7GFiZNw1N13jCnLyMkC1O69nVw4k9Y0Iif7pu0H1eBKZxhkRTGzeECSRtzryqMRpzh9lG11e9SVXA9PNTSTR1%2BC%2FZkOMTsFUk%2Fajh29RmkkGeYrQgCAPEWBST36B3V"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=622lysAXubNaj3TsuhR9RYZRXPc%2BgnyMWj52fxy%2BptvXoPr%2FxVJgJZ0g02mOUjCywdAymkMpawfWCaZVQOIaPVpocco3g4Y%2B0FB667ilf3UtCyiHwqCosUq0T99JabIsgFFJ%2FhP4"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f37347b14e7f9-ORD + - 940b108a2921607d-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27528&min_rtt=26760&rtt_var=11571&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=961&delivery_rate=88005&cwnd=248&unsent_bytes=0&cid=28a3fac05fc0df52&ts=177&x=0" + - cfL4;desc="?proto=TCP&rtt=25729&min_rtt=25575&rtt_var=9899&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=961&delivery_rate=108019&cwnd=251&unsent_bytes=0&cid=ca574e4a637aba29&ts=241&x=0" body: encoding: ASCII-8BIT - string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249832,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Sat, 15 Mar 2025 21:48:33 GMT + string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249739,"date_start":"2024-01-01","date_end":"2024-07-31"}}' + recorded_at: Fri, 16 May 2025 13:01:35 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/health.yml b/test/vcr_cassettes/synth/health.yml index 4d8ca054..24e3e67b 100644 --- a/test/vcr_cassettes/synth/health.yml +++ b/test/vcr_cassettes/synth/health.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:39 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"c5c1d51b68b499d00936c9eb1e8bfdbb" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 0cab64c9-e312-4bec + - 3abc1256-5517-44a7 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 1958563c-7c18-4201-a03c-a4b343dc68ab + - aaf85301-dd16-4b9b-a3a4-c4fbcf1d3f55 X-Runtime: - - '0.014938' + - '0.014386' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=P3OWn4c8LFFWI0Dwr2CSYwHLaNhf9iD9TfAhqdx5PtLoWZ0pSImebfUsh00ZbOmh4r2cRJEQOmvy67wAwl6p0W%2Fx9017EkCnCaXibBBCKqJTBOdGnsSuV%2B45LrHsQmg%2BGeBwrw4b"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=OaVSdNPSl6CQ8gbhnDzkCisX2ILOEWAwweMW3rXXP5rBKuxZoDT024srQWmHKGLsCEhpt4G9mqCthDwlHu2%2BuZ3AyTJQcnBONtE%2FNQ7fKT9x8nLz4mnqL8iyynLuRWQSUJ8SWMj5"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637aa8cf1152-ORD + - 940b109d086eb4b8-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25627&min_rtt=25594&rtt_var=9664&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=878&delivery_rate=111991&cwnd=248&unsent_bytes=0&cid=c8e4c4e269114d14&ts=263&x=0" + - cfL4;desc="?proto=TCP&rtt=32457&min_rtt=26792&rtt_var=14094&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=108091&cwnd=229&unsent_bytes=0&cid=a6f330e4d5f16682&ts=309&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":249830,"api_limit":250000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249733,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:39 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_info.yml b/test/vcr_cassettes/synth/security_info.yml index 8122b882..8dbb76d1 100644 --- a/test/vcr_cassettes/synth/security_info.yml +++ b/test/vcr_cassettes/synth/security_info.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:04:12 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"a9deeb6437d359f080be449b9b2c547b" + - W/"75f336ad88e262c72044e8b865265298" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 1e77ae49-050a-45fc + - ba973abf-7d96-4a9a Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 222dacf1-37f3-4eb8-91d5-edf13d732d46 + - 76cb13a6-0d7e-4c36-8df9-bb63110d9e2a X-Runtime: - - '0.059222' + - '0.099716' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BLW%2Fd%2BbcNg4%2FleO6ECyB4RJBMbm6vWG3%2FX4oKQXfn1ROSPVrISc3ZFVlXfITGW4XYJSPyUDF%2FXrrRF6p3Wzow07QamOrsux7sxBMvtWmcubgpCMFI4zgnhESklW6KcmAefwrgj9i"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=aDn7ApAO9Ma86gZ%2BJKCUCFjH2Re%2BtXdB5gcqYj2KTGXJKNpgf5TNgzbrp5%2Bw%2FGL5nTvtp%2B7cxT8MMcLWjAV6Ne1r6z5YBFq1K4W7Zw5m1lhMiqYLnTnEs2Oq85TjzOvpsE%2BmC33d"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141c97bfd9124c-ORD + - 940b10910abdd2ec-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27459&min_rtt=26850&rtt_var=11288&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=905&delivery_rate=91272&cwnd=104&unsent_bytes=0&cid=ccd6aa7e48e4b0eb&ts=287&x=0" + - cfL4;desc="?proto=TCP&rtt=28163&min_rtt=27237&rtt_var=12066&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=905&delivery_rate=83590&cwnd=239&unsent_bytes=0&cid=7ef62bd693b52ccd&ts=240&x=0" body: encoding: ASCII-8BIT string: '{"data":{"ticker":"AAPL","name":"Apple Inc.","links":{"homepage_url":"https://www.apple.com"},"logo_url":"https://logo.synthfinance.com/ticker/AAPL","description":"Apple @@ -100,6 +100,6 @@ http_interactions: Apple Park Way","city":"Cupertino","state":"CA","postal_code":"95014"},"exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United States","country_code":"US","timezone":"America/New_York"},"ceo":"Mr. Timothy - D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":213.95,"low_today":209.58,"open_today":211.25,"close_today":213.49,"volume_today":60060200.0,"fifty_two_week_high":260.1,"fifty_two_week_low":164.08,"average_volume":62848099.37313433,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249808}}' - recorded_at: Sun, 16 Mar 2025 12:04:12 GMT + D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":212.96,"low_today":209.54,"open_today":210.95,"close_today":211.45,"volume_today":44979900.0,"fifty_two_week_high":260.1,"fifty_two_week_low":169.21,"average_volume":61769396.875,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249737}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_price.yml b/test/vcr_cassettes/synth/security_price.yml index 2e6d1dfb..5cd435d4 100644 --- a/test/vcr_cassettes/synth/security_price.yml +++ b/test/vcr_cassettes/synth/security_price.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:08:00 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"cdf04c2cd77e230c03117dd13d0921f9" + - W/"72340d82266397447b865407dda15492" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e74b3425-0b7c-447d + - 4c3462aa-2471-40b4 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - b906c5e1-18cc-44cc-9085-313ff066a6ce + - bdbc757d-2528-44c3-ae08-9788e8ee15f7 X-Runtime: - - '0.544708' + - '0.034898' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dZNe6qCGGI2XGXgByLr69%2FYrDQdy2FLtnXafxJnlsvyVjrRFiCvmbbIzgF5CDgtj9HZ8RC5Rh9jbuEI6hPokpa3Al4FEIAZB5AbfZ9toP%2Bc5muG%2FuBgHR%2FnIZpsWG%2BQKmBPu9MBa"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=2Mu4PK4XTsAq%2Bn1%2F2yxy%2Blj7kz3ZCiQ9t8ikr2m19BrhQhrqfeUQfPwxbLc1WIgGMIxpPInKYtDVIX3En%2FGpTNQLAeu%2FpuLKv%2BRmCx%2B7u28od5L%2F9%2BLmEhFWqJjs8Y6C1O2a3SKv"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,15 +69,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921422292d0feacc-ORD + - 940b108f29129d03-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=30826&min_rtt=26727&rtt_var=12950&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=108354&cwnd=219&unsent_bytes=0&cid=43c717161effdc57&ts=695&x=0" + - cfL4;desc="?proto=TCP&rtt=27793&min_rtt=26182&rtt_var=13041&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=74111&cwnd=244&unsent_bytes=0&cid=9bcc030369a615fb&ts=210&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249807}}' - recorded_at: Sun, 16 Mar 2025 12:08:00 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249738}}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_prices.yml b/test/vcr_cassettes/synth/security_prices.yml index 1da82461..8f41f56a 100644 --- a/test/vcr_cassettes/synth/security_prices.yml +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:51 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"eb6f73b7cb267ae753291839d20c72e4" + - W/"909e48e0b9ed1f892c1a1e1b4abd3082" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e0119cf5-873c-4315 + - 63af1418-59b9-4111 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 590af10c-b7c1-47a1-9a9a-7f8a5f031734 + - 74da6a68-0bbd-48fb-b52a-0c5a750bd925 X-Runtime: - - '0.511130' + - '0.044404' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=BgSpKtsSHqPqVuO8FUQ0Zb4nT2VXJ9Q%2F3QrLGiZyq1%2FvGm4KnPL2jbgehp8fTKMHqK64Dm4aoEfwI6iK22Gz%2BG9Kq8wpHPGugon0YRBz1tiYLnq5QVoyvNJi6HV%2B6IBWQ5jCK5wA"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=nwA0tsfR9it%2B9%2BtfHGjyzyfiSqPdNGxQqOLNF%2BIqlTeT1wJT6gLDCtbd1WFpOc1f8UXm2Zjn%2FJDOf7jOKWmGN6SKUBBjZvFLlBq%2FWyC7DN55NJwwyO77vD%2F5nf%2FaqduWCdPx7n7m"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,17 +69,17 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a99ed232306-ORD + - 940b10932ec92305-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26790&min_rtt=26357&rtt_var=10751&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=97096&cwnd=126&unsent_bytes=0&cid=5ac523d87c018022&ts=666&x=0" + - cfL4;desc="?proto=TCP&rtt=27451&min_rtt=26715&rtt_var=11491&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=88818&cwnd=249&unsent_bytes=0&cid=63105dfc059c15ef&ts=344&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":249810}}' - recorded_at: Sun, 16 Mar 2025 12:02:51 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249736}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT - request: method: get uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=2&start_date=2024-01-01 @@ -94,7 +94,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -105,7 +105,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:52 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -115,11 +115,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"78f6663a1523295a82d0ded13df426e4" + - W/"bbc82ef9591694561dd9992a9c06d491" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - b0cd3704-937c-4017 + - 63ebee52-f1b2-4e81 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -133,15 +133,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 59a55ec3-49af-4fa1-a104-77480fa6914e + - dd95cb59-aead-4d1e-b1a2-881696e742fb X-Runtime: - - '0.583469' + - '0.031482' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ze9Rfqww2cEeTSTiP5axby5TPvYyBZDoEHRZKniMMybJrqYiBI1oGlCViODsaOXisw23njq1YaO%2Fhc0yGlPaqYdTcMXc6bQbVnWANjASqMS%2BQoVmPBFPr3nvSqeU99huB4BKWGlY"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ebrOdAId1yoCYepT0CPKImNIA%2BOe8V3W3BHYheEOkVQFLsffFpfl%2B%2BYXfEHL21wczvW5dkZSd3OrF%2FklB%2FwGGDahXpveXzf497azY1Ho4YJrtDJeghxyZV6J%2BALPYwwpGrfUpv%2F1"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -149,15 +149,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a9edbeee15f-ORD + - 940b1095ccd41156-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=32376&min_rtt=31508&rtt_var=12435&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=91913&cwnd=171&unsent_bytes=0&cid=d782914cf2ed620a&ts=758&x=0" + - cfL4;desc="?proto=TCP&rtt=26344&min_rtt=26162&rtt_var=10175&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=104847&cwnd=237&unsent_bytes=0&cid=9603fe0eb1df39aa&ts=212&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":249809}}' - recorded_at: Sun, 16 Mar 2025 12:02:52 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249735}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_search.yml b/test/vcr_cassettes/synth/security_search.yml index f9504804..c06c0f42 100644 --- a/test/vcr_cassettes/synth/security_search.yml +++ b/test/vcr_cassettes/synth/security_search.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:01:58 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -39,7 +39,7 @@ http_interactions: Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 2effb56b-f67f-402d + - 701ae22a-18c8-4e62 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 33470619-5119-4923-b4e0-e9a0eeb532a1 + - edb55bc6-e3ea-470b-b7af-9b4d9883420b X-Runtime: - - '0.453770' + - '0.355152' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ayZOlXkCwLgUl%2FrB2%2BlqtqR5HCllubf4HLDipEt3klWKyHS4nilHi9XZ1fiEQWx7xwiRMJZ5EW0Xzm7ISoHWTtEbkgMQHWYQwSTeg30ahFFHK1pkOOnET1fuW1UxiZwlJtq1XZGB"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=QGeBWdYED%2F%2FgT9BzborFAnM%2FG6UiNmI0ej212XGHWdFwYXUvTJ2GyqA9hMJrpYIvgbHdQ9Ed0MsQUv3KFb57VXQq0T6UXTNPa%2BFRPepK0hsXeGDLxch04v6KnkTATqcw2M8HuYHS"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921419514e0a6399-ORD + - 940b1097a830f856-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25809&min_rtt=25801&rtt_var=9692&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=939&delivery_rate=111952&cwnd=121&unsent_bytes=0&cid=2beb787f15cd8ab9&ts=610&x=0" + - cfL4;desc="?proto=TCP&rtt=26401&min_rtt=25556&rtt_var=11273&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=939&delivery_rate=89615&cwnd=244&unsent_bytes=0&cid=cf6d0758d165295d&ts=500&x=0" body: encoding: ASCII-8BIT string: '{"data":[{"symbol":"AAPL","name":"Apple Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs @@ -100,5 +100,5 @@ http_interactions: Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPJ","currency":"USD","exchange":{"name":"Otc Pink Marketplace","mic_code":"PINX","operating_mic_code":"OTCM","acronym":"","country":"United States","country_code":"US","timezone":"America/New_York"}}]}' - recorded_at: Sun, 16 Mar 2025 12:01:58 GMT + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/transaction_enrich.yml b/test/vcr_cassettes/synth/transaction_enrich.yml deleted file mode 100644 index 08463ed7..00000000 --- a/test/vcr_cassettes/synth/transaction_enrich.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.synthfinance.com/enrich?amount=25.5&city=San%20Francisco&country=US&date=2025-03-16&description=UBER%20EATS&state=CA - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.12.2 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Sun, 16 Mar 2025 12:09:33 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"00411c83cfeaade519bcc3e57d9e461e" - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - 56a8791d-85ed-4342 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 1b35b9c1-0092-40b1-8b70-2bce7c5796af - X-Runtime: - - '0.884634' - X-Xss-Protection: - - '0' - Cf-Cache-Status: - - DYNAMIC - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=qUtB0aWbK%2Fh5W7cV%2FugsUGbWKtJzsf%2FXd5i8cm8KlepEtLyuVPH7XX0fqwzHp43OCWQkGr9r8hRBBSEcx9LWW5vS7%2B1kXCJaKPaTRn%2BWtsEymHg78OHqDcMahwSuy%2FkpSGLWo0or"}],"group":"cf-nel","max_age":604800}' - Nel: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Speculation-Rules: - - '"/cdn-cgi/speculation"' - Server: - - cloudflare - Cf-Ray: - - 921424681aa4acab-ORD - Alt-Svc: - - h3=":443"; ma=86400 - Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26975&min_rtt=26633&rtt_var=10231&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=969&delivery_rate=108737&cwnd=210&unsent_bytes=0&cid=318ff675628918e1&ts=1035&x=0" - body: - encoding: ASCII-8BIT - string: '{"merchant":"Uber Eats","merchant_id":"mer_aea41e7f29ce47b5873f3caf49d5972d","category":"Dining - Out","website":"ubereats.com","icon":"https://logo.synthfinance.com/ubereats.com","meta":{"credits_used":1,"credits_remaining":249806}}' - recorded_at: Sun, 16 Mar 2025 12:09:33 GMT -recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/usage.yml b/test/vcr_cassettes/synth/usage.yml index 27e5300f..d60922db 100644 --- a/test/vcr_cassettes/synth/usage.yml +++ b/test/vcr_cassettes/synth/usage.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"7b8c2bf0cba54bc26b78bdc6e611dcbd" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 54c8ecf9-6858-4db6 + - 1b53adf6-b391-45b2 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - a4112cfb-0eac-4e3e-a880-7536d90dcba0 + - f88670a2-81d2-48b6-8d73-a911c846e330 X-Runtime: - - '0.007036' + - '0.018749' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Rt0BTtrgXzYjWOQFgb%2Bg6N4xKvXtPI66Q251bq9nWtqUhGHo17GmVVAPkutwN7Gisw1RmvYfxYUiMCCxlc4%2BjuHxbU1%2BXr9KHy%2F5pUpLhgLNNrtkqqKOCW4GduODnDbw2I38Rocu"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=oH4OsWB6itK0jpi%2FPs%2BswVyCZIbkJGPfyJaoR4TKFtTAfmnqa8Lp6aZhv22WKzotXJuAKbh99VdYdZIOkeIPWbYTc6j4rGw%2BkQB3Hw%2Fc44QxDBJFdIo6wJNe8TGiPAZ%2BvgoBVHWn"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637d1fe8eb68-ORD + - 940b108c38f66392-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=28779&min_rtt=27036&rtt_var=11384&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=878&delivery_rate=107116&cwnd=203&unsent_bytes=0&cid=52bc39ad09dd9eff&ts=145&x=0" + - cfL4;desc="?proto=TCP&rtt=33369&min_rtt=25798&rtt_var=15082&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=112256&cwnd=205&unsent_bytes=0&cid=1b13324eb0768fd3&ts=285&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":1200,"api_limit":5000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249738,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1 From 35d1447494050455a6df45264dda07360480c72b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 16 May 2025 14:25:35 -0400 Subject: [PATCH 08/28] Adjust Sentry missing price and rate to warning level --- app/models/exchange_rate/syncer.rb | 2 +- app/models/security/price/syncer.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/exchange_rate/syncer.rb b/app/models/exchange_rate/syncer.rb index 1f73bc8e..a1ee44b5 100644 --- a/app/models/exchange_rate/syncer.rb +++ b/app/models/exchange_rate/syncer.rb @@ -121,7 +121,7 @@ class ExchangeRate::Syncer else message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}" Rails.logger.warn(message) - Sentry.capture_exception(MissingExchangeRateError.new(message)) + Sentry.capture_exception(MissingExchangeRateError.new(message), level: :warning) {} end end diff --git a/app/models/security/price/syncer.rb b/app/models/security/price/syncer.rb index dbdf0831..824998cd 100644 --- a/app/models/security/price/syncer.rb +++ b/app/models/security/price/syncer.rb @@ -77,7 +77,7 @@ class Security::Price::Syncer else msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}" Rails.logger.warn(msg) - Sentry.capture_exception(MissingSecurityPriceError.new(msg)) + Sentry.capture_exception(MissingSecurityPriceError.new(msg), level: :warning) {} end end From b8903d0980e89f904fd7404a5f75a34887e780dc Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 16 May 2025 14:40:52 -0400 Subject: [PATCH 09/28] Fix start date missing error in market data sync --- app/models/market_data_syncer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/market_data_syncer.rb b/app/models/market_data_syncer.rb index 70d60b75..5567ba31 100644 --- a/app/models/market_data_syncer.rb +++ b/app/models/market_data_syncer.rb @@ -101,14 +101,14 @@ class MarketDataSyncer def get_first_required_price_date(security) return default_start_date if snapshot? - Trade.with_entry.where(security: security).minimum(:date) + Trade.with_entry.where(security: security).minimum(:date) || default_start_date end # An approximation that grabs more than we likely need, but simplifies the logic def get_first_required_exchange_rate_date(from_currency:) return default_start_date if snapshot? - Entry.where(currency: from_currency).minimum(:date) + Entry.where(currency: from_currency).minimum(:date) || default_start_date end def default_start_date From 10f255a9a9399064a9bed64dd4b6efa0fc03eaf9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 17 May 2025 16:37:16 -0400 Subject: [PATCH 10/28] Clarify backend data pipeline naming concepts (importers, processors, materializers, calculators, and syncers) (#2255) * Rename MarketDataSyncer to MarketDataImporter * Materializers * Importers * More reference replacements --- ..._data_job.rb => import_market_data_job.rb} | 4 +- app/models/account/market_data_importer.rb | 82 +++++++++++++++++++ app/models/account/market_data_syncer.rb | 82 ------------------- app/models/account/syncer.rb | 12 +-- .../balance/{syncer.rb => materializer.rb} | 10 +-- .../exchange_rate/{syncer.rb => importer.rb} | 4 +- app/models/exchange_rate/provided.rb | 8 +- .../holding/{syncer.rb => materializer.rb} | 6 +- ...data_syncer.rb => market_data_importer.rb} | 24 +++--- .../security/price/{syncer.rb => importer.rb} | 4 +- app/models/security/provided.rb | 12 +-- config/schedule.yml | 6 +- ...r_test.rb => market_data_importer_test.rb} | 8 +- .../{syncer_test.rb => materializer_test.rb} | 8 +- .../{syncer_test.rb => importer_test.rb} | 22 ++--- .../{syncer_test.rb => materializer_test.rb} | 6 +- ...r_test.rb => market_data_importer_test.rb} | 8 +- .../{syncer_test.rb => importer_test.rb} | 22 ++--- 18 files changed, 165 insertions(+), 163 deletions(-) rename app/jobs/{sync_market_data_job.rb => import_market_data_job.rb} (86%) create mode 100644 app/models/account/market_data_importer.rb delete mode 100644 app/models/account/market_data_syncer.rb rename app/models/balance/{syncer.rb => materializer.rb} (89%) rename app/models/exchange_rate/{syncer.rb => importer.rb} (98%) rename app/models/holding/{syncer.rb => materializer.rb} (87%) rename app/models/{market_data_syncer.rb => market_data_importer.rb} (84%) rename app/models/security/price/{syncer.rb => importer.rb} (98%) rename test/models/account/{market_data_syncer_test.rb => market_data_importer_test.rb} (92%) rename test/models/balance/{syncer_test.rb => materializer_test.rb} (82%) rename test/models/exchange_rate/{syncer_test.rb => importer_test.rb} (92%) rename test/models/holding/{syncer_test.rb => materializer_test.rb} (78%) rename test/models/{market_data_syncer_test.rb => market_data_importer_test.rb} (91%) rename test/models/security/price/{syncer_test.rb => importer_test.rb} (92%) diff --git a/app/jobs/sync_market_data_job.rb b/app/jobs/import_market_data_job.rb similarity index 86% rename from app/jobs/sync_market_data_job.rb rename to app/jobs/import_market_data_job.rb index db34a41a..294d5836 100644 --- a/app/jobs/sync_market_data_job.rb +++ b/app/jobs/import_market_data_job.rb @@ -7,7 +7,7 @@ # Each individual account sync will still fetch any missing market data that isn't yet synced, but by running # this job daily, we significantly reduce overlapping account syncs that both need the same market data (e.g. common security like `AAPL`) # -class SyncMarketDataJob < ApplicationJob +class ImportMarketDataJob < ApplicationJob queue_as :scheduled def perform(opts) @@ -15,6 +15,6 @@ class SyncMarketDataJob < ApplicationJob mode = opts.fetch(:mode, :full) clear_cache = opts.fetch(:clear_cache, false) - MarketDataSyncer.new(mode: mode, clear_cache: clear_cache).sync + MarketDataImporter.new(mode: mode, clear_cache: clear_cache).import_all end end diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb new file mode 100644 index 00000000..af538314 --- /dev/null +++ b/app/models/account/market_data_importer.rb @@ -0,0 +1,82 @@ +class Account::MarketDataImporter + attr_reader :account + + def initialize(account) + @account = account + end + + def import_all + import_exchange_rates + import_security_prices + end + + def import_exchange_rates + return unless needs_exchange_rates? + return unless ExchangeRate.provider + + pair_dates = {} + + # 1. ENTRY-BASED PAIRS – currencies that differ from the account currency + account.entries + .where.not(currency: account.currency) + .group(:currency) + .minimum(:date) + .each do |source_currency, date| + key = [ source_currency, account.currency ] + pair_dates[key] = [ pair_dates[key], date ].compact.min + end + + # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different) + if foreign_account? + key = [ account.currency, account.family.currency ] + pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min + end + + pair_dates.each do |(source, target), start_date| + ExchangeRate.import_provider_rates( + from: source, + to: target, + start_date: start_date, + end_date: Date.current + ) + end + end + + def import_security_prices + return unless Security.provider + + account_securities = account.trades.map(&:security).uniq + + return if account_securities.empty? + + account_securities.each do |security| + security.import_provider_prices( + start_date: first_required_price_date(security), + end_date: Date.current + ) + + security.import_provider_details + end + end + + private + # Calculates the first date we require a price for the given security scoped to this account + def first_required_price_date(security) + account.trades.with_entry + .where(security: security) + .where(entries: { account_id: account.id }) + .minimum("entries.date") + end + + def needs_exchange_rates? + has_multi_currency_entries? || foreign_account? + end + + def has_multi_currency_entries? + account.entries.where.not(currency: account.currency).exists? + end + + def foreign_account? + account.currency != account.family.currency + end +end diff --git a/app/models/account/market_data_syncer.rb b/app/models/account/market_data_syncer.rb deleted file mode 100644 index b223d229..00000000 --- a/app/models/account/market_data_syncer.rb +++ /dev/null @@ -1,82 +0,0 @@ -class Account::MarketDataSyncer - attr_reader :account - - def initialize(account) - @account = account - end - - def sync_market_data - sync_exchange_rates - sync_security_prices - end - - private - def sync_exchange_rates - return unless needs_exchange_rates? - return unless ExchangeRate.provider - - pair_dates = {} - - # 1. ENTRY-BASED PAIRS – currencies that differ from the account currency - account.entries - .where.not(currency: account.currency) - .group(:currency) - .minimum(:date) - .each do |source_currency, date| - key = [ source_currency, account.currency ] - pair_dates[key] = [ pair_dates[key], date ].compact.min - end - - # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different) - if foreign_account? - key = [ account.currency, account.family.currency ] - pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min - end - - pair_dates.each do |(source, target), start_date| - ExchangeRate.sync_provider_rates( - from: source, - to: target, - start_date: start_date, - end_date: Date.current - ) - end - end - - def sync_security_prices - return unless Security.provider - - account_securities = account.trades.map(&:security).uniq - - return if account_securities.empty? - - account_securities.each do |security| - security.sync_provider_prices( - start_date: first_required_price_date(security), - end_date: Date.current - ) - - security.sync_provider_details - end - end - - # Calculates the first date we require a price for the given security scoped to this account - def first_required_price_date(security) - account.trades.with_entry - .where(security: security) - .where(entries: { account_id: account.id }) - .minimum("entries.date") - end - - def needs_exchange_rates? - has_multi_currency_entries? || foreign_account? - end - - def has_multi_currency_entries? - account.entries.where.not(currency: account.currency).exists? - end - - def foreign_account? - account.currency != account.family.currency - end -end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index de63f5e8..ab198a95 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -7,8 +7,8 @@ class Account::Syncer def perform_sync(sync) Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") - sync_market_data - sync_balances + import_market_data + materialize_balances end def perform_post_sync @@ -16,9 +16,9 @@ class Account::Syncer end private - def sync_balances + def materialize_balances strategy = account.linked? ? :reverse : :forward - Balance::Syncer.new(account, strategy: strategy).sync_balances + Balance::Materializer.new(account, strategy: strategy).materialize_balances end # Syncs all the exchange rates + security prices this account needs to display historical chart data @@ -28,8 +28,8 @@ class Account::Syncer # # We rescue errors here because if this operation fails, we don't want to fail the entire sync since # we have reasonable fallbacks for missing market data. - def sync_market_data - Account::MarketDataSyncer.new(account).sync_market_data + def import_market_data + Account::MarketDataImporter.new(account).import_all rescue => e Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") Sentry.capture_exception(e) diff --git a/app/models/balance/syncer.rb b/app/models/balance/materializer.rb similarity index 89% rename from app/models/balance/syncer.rb rename to app/models/balance/materializer.rb index 890bb5f9..75a98ffd 100644 --- a/app/models/balance/syncer.rb +++ b/app/models/balance/materializer.rb @@ -1,4 +1,4 @@ -class Balance::Syncer +class Balance::Materializer attr_reader :account, :strategy def initialize(account, strategy:) @@ -6,9 +6,9 @@ class Balance::Syncer @strategy = strategy end - def sync_balances + def materialize_balances Balance.transaction do - sync_holdings + materialize_holdings calculate_balances Rails.logger.info("Persisting #{@balances.size} balances") @@ -23,8 +23,8 @@ class Balance::Syncer end private - def sync_holdings - @holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings + def materialize_holdings + @holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings end def update_account_info diff --git a/app/models/exchange_rate/syncer.rb b/app/models/exchange_rate/importer.rb similarity index 98% rename from app/models/exchange_rate/syncer.rb rename to app/models/exchange_rate/importer.rb index a1ee44b5..133106bc 100644 --- a/app/models/exchange_rate/syncer.rb +++ b/app/models/exchange_rate/importer.rb @@ -1,4 +1,4 @@ -class ExchangeRate::Syncer +class ExchangeRate::Importer MissingExchangeRateError = Class.new(StandardError) MissingStartRateError = Class.new(StandardError) @@ -12,7 +12,7 @@ class ExchangeRate::Syncer end # Constructs a daily series of rates for the given currency pair for date range - def sync_provider_rates + def import_provider_rates if !clear_cache && all_rates_exist? Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping") return diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 5a1b4c60..defee421 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -28,20 +28,20 @@ module ExchangeRate::Provided end # @return [Integer] The number of exchange rates synced - def sync_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false) + def import_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false) unless provider.present? - Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates") + Rails.logger.warn("No provider configured for ExchangeRate.import_provider_rates") return 0 end - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: provider, from: from, to: to, start_date: start_date, end_date: end_date, clear_cache: clear_cache - ).sync_provider_rates + ).import_provider_rates end end end diff --git a/app/models/holding/syncer.rb b/app/models/holding/materializer.rb similarity index 87% rename from app/models/holding/syncer.rb rename to app/models/holding/materializer.rb index 345f2a3f..e4931128 100644 --- a/app/models/holding/syncer.rb +++ b/app/models/holding/materializer.rb @@ -1,10 +1,12 @@ -class Holding::Syncer +# "Materializes" holdings (similar to a DB materialized view, but done at the app level) +# into a series of records we can easily query and join with other data. +class Holding::Materializer def initialize(account, strategy:) @account = account @strategy = strategy end - def sync_holdings + def materialize_holdings calculate_holdings Rails.logger.info("Persisting #{@holdings.size} holdings") diff --git a/app/models/market_data_syncer.rb b/app/models/market_data_importer.rb similarity index 84% rename from app/models/market_data_syncer.rb rename to app/models/market_data_importer.rb index 5567ba31..9eaf5964 100644 --- a/app/models/market_data_syncer.rb +++ b/app/models/market_data_importer.rb @@ -1,4 +1,4 @@ -class MarketDataSyncer +class MarketDataImporter # By default, our graphs show 1M as the view, so by fetching 31 days, # we ensure we can always show an accurate default graph SNAPSHOT_DAYS = 31 @@ -10,32 +10,32 @@ class MarketDataSyncer @clear_cache = clear_cache end - def sync - sync_prices - sync_exchange_rates + def import_all + import_security_prices + import_exchange_rates end # Syncs historical security prices (and details) - def sync_prices + def import_security_prices unless Security.provider - Rails.logger.warn("No provider configured for MarketDataSyncer.sync_prices, skipping sync") + Rails.logger.warn("No provider configured for MarketDataImporter.import_security_prices, skipping sync") return end Security.where.not(exchange_operating_mic: nil).find_each do |security| - security.sync_provider_prices( + security.import_provider_prices( start_date: get_first_required_price_date(security), end_date: end_date, clear_cache: clear_cache ) - security.sync_provider_details(clear_cache: clear_cache) + security.import_provider_details(clear_cache: clear_cache) end end - def sync_exchange_rates + def import_exchange_rates unless ExchangeRate.provider - Rails.logger.warn("No provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync") + Rails.logger.warn("No provider configured for MarketDataImporter.import_exchange_rates, skipping sync") return end @@ -43,7 +43,7 @@ class MarketDataSyncer # pair is a Hash with keys :source, :target, and :start_date start_date = snapshot? ? default_start_date : pair[:start_date] - ExchangeRate.sync_provider_rates( + ExchangeRate.import_provider_rates( from: pair[:source], to: pair[:target], start_date: start_date, @@ -124,7 +124,7 @@ class MarketDataSyncer valid_modes = [ :full, :snapshot ] unless valid_modes.include?(mode.to_sym) - raise InvalidModeError, "Invalid mode for MarketDataSyncer, can only be :full or :snapshot, but was #{mode}" + raise InvalidModeError, "Invalid mode for MarketDataImporter, can only be :full or :snapshot, but was #{mode}" end mode.to_sym diff --git a/app/models/security/price/syncer.rb b/app/models/security/price/importer.rb similarity index 98% rename from app/models/security/price/syncer.rb rename to app/models/security/price/importer.rb index 824998cd..4e6bee2f 100644 --- a/app/models/security/price/syncer.rb +++ b/app/models/security/price/importer.rb @@ -1,4 +1,4 @@ -class Security::Price::Syncer +class Security::Price::Importer MissingSecurityPriceError = Class.new(StandardError) MissingStartPriceError = Class.new(StandardError) @@ -12,7 +12,7 @@ class Security::Price::Syncer # Constructs a daily series of prices for a single security over the date range. # Returns the number of rows upserted. - def sync_provider_prices + def import_provider_prices if !clear_cache && all_prices_exist? Rails.logger.info("No new prices to sync for #{security.ticker} between #{start_date} and #{end_date}, skipping") return 0 diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 2214ccfa..2fdcc607 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -49,9 +49,9 @@ module Security::Provided price end - def sync_provider_details(clear_cache: false) + def import_provider_details(clear_cache: false) unless provider.present? - Rails.logger.warn("No provider configured for Security.sync_provider_details") + Rails.logger.warn("No provider configured for Security.import_provider_details") return end @@ -76,19 +76,19 @@ module Security::Provided end end - def sync_provider_prices(start_date:, end_date:, clear_cache: false) + def import_provider_prices(start_date:, end_date:, clear_cache: false) unless provider.present? - Rails.logger.warn("No provider configured for Security.sync_provider_prices") + Rails.logger.warn("No provider configured for Security.import_provider_prices") return 0 end - Security::Price::Syncer.new( + Security::Price::Importer.new( security: self, security_provider: provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache - ).sync_provider_prices + ).import_provider_prices end private diff --git a/config/schedule.yml b/config/schedule.yml index 8eb8ef0a..28078e4d 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -1,8 +1,8 @@ -sync_market_data: +import_market_data: cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) - class: "SyncMarketDataJob" + class: "ImportMarketDataJob" queue: "scheduled" - description: "Syncs 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)" args: mode: "full" clear_cache: false diff --git a/test/models/account/market_data_syncer_test.rb b/test/models/account/market_data_importer_test.rb similarity index 92% rename from test/models/account/market_data_syncer_test.rb rename to test/models/account/market_data_importer_test.rb index 596798f5..74c42d36 100644 --- a/test/models/account/market_data_syncer_test.rb +++ b/test/models/account/market_data_importer_test.rb @@ -1,7 +1,7 @@ require "test_helper" require "ostruct" -class Account::MarketDataSyncerTest < ActiveSupport::TestCase +class Account::MarketDataImporterTest < ActiveSupport::TestCase include ProviderTestHelper PROVIDER_BUFFER = 5.days @@ -32,7 +32,7 @@ class Account::MarketDataSyncerTest < ActiveSupport::TestCase accountable: Depository.new ) - # Seed a rate for the first required day so that the syncer only needs the next day forward + # Seed a rate for the first required day so that the importer only needs the next day forward existing_date = account.start_date ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0) @@ -49,7 +49,7 @@ class Account::MarketDataSyncerTest < ActiveSupport::TestCase ])) before = ExchangeRate.count - Account::MarketDataSyncer.new(account).sync_market_data + Account::MarketDataImporter.new(account).import_all after = ExchangeRate.count assert_operator after, :>, before, "Should insert at least one new exchange-rate row" @@ -100,7 +100,7 @@ class Account::MarketDataSyncerTest < ActiveSupport::TestCase # Ignore exchange-rate calls for this test @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) - Account::MarketDataSyncer.new(account).sync_market_data + Account::MarketDataImporter.new(account).import_all assert_equal 1, Security::Price.where(security: security, date: trade_date).count end diff --git a/test/models/balance/syncer_test.rb b/test/models/balance/materializer_test.rb similarity index 82% rename from test/models/balance/syncer_test.rb rename to test/models/balance/materializer_test.rb index 648f6b3a..4a5ac439 100644 --- a/test/models/balance/syncer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Balance::SyncerTest < ActiveSupport::TestCase +class Balance::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper setup do @@ -14,7 +14,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase end test "syncs balances" do - Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once @account.expects(:start_date).returns(2.days.ago.to_date) @@ -26,7 +26,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase ) assert_difference "@account.balances.count", 2 do - Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Materializer.new(@account, strategy: :forward).materialize_balances end end @@ -45,7 +45,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase ) assert_difference "@account.balances.count", 3 do - Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Materializer.new(@account, strategy: :forward).materialize_balances end end end diff --git a/test/models/exchange_rate/syncer_test.rb b/test/models/exchange_rate/importer_test.rb similarity index 92% rename from test/models/exchange_rate/syncer_test.rb rename to test/models/exchange_rate/importer_test.rb index 58818834..dab40fa8 100644 --- a/test/models/exchange_rate/syncer_test.rb +++ b/test/models/exchange_rate/importer_test.rb @@ -1,7 +1,7 @@ require "test_helper" require "ostruct" -class ExchangeRate::SyncerTest < ActiveSupport::TestCase +class ExchangeRate::ImporterTest < ActiveSupport::TestCase include ProviderTestHelper setup do @@ -21,13 +21,13 @@ class ExchangeRate::SyncerTest < ActiveSupport::TestCase .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) .returns(provider_response) - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: @provider, from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current - ).sync_provider_rates + ).import_provider_rates db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date..Date.current) .order(:date) @@ -53,13 +53,13 @@ class ExchangeRate::SyncerTest < ActiveSupport::TestCase .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) .returns(provider_response) - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: @provider, from: "USD", to: "EUR", start_date: 3.days.ago.to_date, end_date: Date.current - ).sync_provider_rates + ).import_provider_rates db_rates = ExchangeRate.order(:date) assert_equal 4, db_rates.count @@ -75,13 +75,13 @@ class ExchangeRate::SyncerTest < ActiveSupport::TestCase @provider.expects(:fetch_exchange_rates).never - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: @provider, from: "USD", to: "EUR", start_date: 3.days.ago.to_date, end_date: Date.current - ).sync_provider_rates + ).import_provider_rates end # A helpful "reset" option for when we need to refresh provider data @@ -103,14 +103,14 @@ class ExchangeRate::SyncerTest < ActiveSupport::TestCase .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) .returns(provider_response) - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: @provider, from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current, clear_cache: true - ).sync_provider_rates + ).import_provider_rates db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) assert_equal [ 1.3, 1.4, 1.5 ], db_rates.map(&:rate) @@ -129,13 +129,13 @@ class ExchangeRate::SyncerTest < ActiveSupport::TestCase .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) .returns(provider_response) - ExchangeRate::Syncer.new( + ExchangeRate::Importer.new( exchange_rate_provider: @provider, from: "USD", to: "EUR", start_date: Date.current, end_date: future_date - ).sync_provider_rates + ).import_provider_rates assert_equal 1, ExchangeRate.count end diff --git a/test/models/holding/syncer_test.rb b/test/models/holding/materializer_test.rb similarity index 78% rename from test/models/holding/syncer_test.rb rename to test/models/holding/materializer_test.rb index 19e191c8..41ae8ff6 100644 --- a/test/models/holding/syncer_test.rb +++ b/test/models/holding/materializer_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Holding::SyncerTest < ActiveSupport::TestCase +class Holding::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper setup do @@ -14,7 +14,7 @@ class Holding::SyncerTest < ActiveSupport::TestCase # Should have yesterday's and today's holdings assert_difference "@account.holdings.count", 2 do - Holding::Syncer.new(@account, strategy: :forward).sync_holdings + Holding::Materializer.new(@account, strategy: :forward).materialize_holdings end end @@ -23,7 +23,7 @@ class Holding::SyncerTest < ActiveSupport::TestCase Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current) assert_difference "Holding.count", -1 do - Holding::Syncer.new(@account, strategy: :forward).sync_holdings + Holding::Materializer.new(@account, strategy: :forward).materialize_holdings end end end diff --git a/test/models/market_data_syncer_test.rb b/test/models/market_data_importer_test.rb similarity index 91% rename from test/models/market_data_syncer_test.rb rename to test/models/market_data_importer_test.rb index 8a9db1f5..b39bf0ad 100644 --- a/test/models/market_data_syncer_test.rb +++ b/test/models/market_data_importer_test.rb @@ -1,10 +1,10 @@ require "test_helper" require "ostruct" -class MarketDataSyncerTest < ActiveSupport::TestCase +class MarketDataImporterTest < ActiveSupport::TestCase include ProviderTestHelper - SNAPSHOT_START_DATE = MarketDataSyncer::SNAPSHOT_DAYS.days.ago.to_date + SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date PROVIDER_BUFFER = 5.days setup do @@ -47,7 +47,7 @@ class MarketDataSyncerTest < ActiveSupport::TestCase ])) before = ExchangeRate.count - MarketDataSyncer.new(mode: :snapshot).sync_exchange_rates + MarketDataImporter.new(mode: :snapshot).import_exchange_rates after = ExchangeRate.count assert_operator after, :>, before, "Should insert at least one new exchange-rate row" @@ -78,7 +78,7 @@ class MarketDataSyncerTest < ActiveSupport::TestCase # Ignore exchange rate calls for this test @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) - MarketDataSyncer.new(mode: :snapshot).sync_prices + MarketDataImporter.new(mode: :snapshot).import_security_prices assert_equal 1, Security::Price.where(security: security, date: SNAPSHOT_START_DATE).count end diff --git a/test/models/security/price/syncer_test.rb b/test/models/security/price/importer_test.rb similarity index 92% rename from test/models/security/price/syncer_test.rb rename to test/models/security/price/importer_test.rb index 25a3f14c..665a91f6 100644 --- a/test/models/security/price/syncer_test.rb +++ b/test/models/security/price/importer_test.rb @@ -1,7 +1,7 @@ require "test_helper" require "ostruct" -class Security::Price::SyncerTest < ActiveSupport::TestCase +class Security::Price::ImporterTest < ActiveSupport::TestCase include ProviderTestHelper setup do @@ -23,12 +23,12 @@ class Security::Price::SyncerTest < ActiveSupport::TestCase start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) .returns(provider_response) - Security::Price::Syncer.new( + Security::Price::Importer.new( security: @security, security_provider: @provider, start_date: 2.days.ago.to_date, end_date: Date.current - ).sync_provider_prices + ).import_provider_prices db_prices = Security::Price.where(security: @security, date: 2.days.ago.to_date..Date.current).order(:date) @@ -52,12 +52,12 @@ class Security::Price::SyncerTest < ActiveSupport::TestCase start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) .returns(provider_response) - Security::Price::Syncer.new( + Security::Price::Importer.new( security: @security, security_provider: @provider, start_date: 3.days.ago.to_date, end_date: Date.current - ).sync_provider_prices + ).import_provider_prices db_prices = Security::Price.where(security: @security).order(:date) assert_equal 4, db_prices.count @@ -73,12 +73,12 @@ class Security::Price::SyncerTest < ActiveSupport::TestCase @provider.expects(:fetch_security_prices).never - Security::Price::Syncer.new( + Security::Price::Importer.new( security: @security, security_provider: @provider, start_date: 3.days.ago.to_date, end_date: Date.current - ).sync_provider_prices + ).import_provider_prices end test "full upsert if clear_cache is true" do @@ -100,13 +100,13 @@ class Security::Price::SyncerTest < ActiveSupport::TestCase start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) .returns(provider_response) - Security::Price::Syncer.new( + Security::Price::Importer.new( security: @security, security_provider: @provider, start_date: 2.days.ago.to_date, end_date: Date.current, clear_cache: true - ).sync_provider_prices + ).import_provider_prices db_prices = Security::Price.where(security: @security).order(:date) assert_equal [ 150, 155, 160 ], db_prices.map(&:price) @@ -126,12 +126,12 @@ class Security::Price::SyncerTest < ActiveSupport::TestCase start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) .returns(provider_response) - Security::Price::Syncer.new( + Security::Price::Importer.new( security: @security, security_provider: @provider, start_date: Date.current, end_date: future_date - ).sync_provider_prices + ).import_provider_prices assert_equal 1, Security::Price.count end From 9f13b5bb8333284be9bcbf2af602ec87ff033555 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 17 May 2025 18:28:21 -0400 Subject: [PATCH 11/28] Handle stale syncs (#2257) * Handle stale syncs * Use `visible` sync logic in sidebar groups --- app/jobs/sync_cleaner_job.rb | 7 +++++++ app/models/account.rb | 2 +- app/models/balance_sheet.rb | 6 +++++- app/models/concerns/syncable.rb | 15 +++++++++++++-- app/models/family.rb | 2 +- app/models/plaid_item.rb | 2 +- app/models/sync.rb | 25 ++++++++++++++++++++++++- config/schedule.yml | 6 ++++++ test/models/sync_test.rb | 21 +++++++++++++++++++++ 9 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 app/jobs/sync_cleaner_job.rb diff --git a/app/jobs/sync_cleaner_job.rb b/app/jobs/sync_cleaner_job.rb new file mode 100644 index 00000000..f5e42551 --- /dev/null +++ b/app/jobs/sync_cleaner_job.rb @@ -0,0 +1,7 @@ +class SyncCleanerJob < ApplicationJob + queue_as :scheduled + + def perform + Sync.clean + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 13734071..4984fb89 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -62,7 +62,7 @@ class Account < ApplicationRecord end def syncing? - self_syncing = syncs.incomplete.any? + self_syncing = syncs.visible.any? # Since Plaid Items sync as a "group", if the item is syncing, even if the account # sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI. diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index cc50e3da..a89a9859 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -107,7 +107,11 @@ class BalanceSheet def totals_query @totals_query ||= active_accounts .joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ])) - .joins("LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND (syncs.status = 'pending' OR syncs.status = 'syncing')") + .joins(ActiveRecord::Base.sanitize_sql_array([ + "LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND syncs.status IN (?) AND syncs.created_at > ?", + %w[pending syncing], + Sync::VISIBLE_FOR.ago + ])) .select( "accounts.*", "SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance", diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 8dfa8e41..9b5e09e4 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -10,8 +10,19 @@ module Syncable end def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil) - new_sync = syncs.create!(parent: parent_sync, window_start_date: window_start_date, window_end_date: window_end_date) - SyncJob.perform_later(new_sync) + Sync.transaction do + # Expire any previous in-flight syncs for this record that exceeded the + # global staleness window. + syncs.stale_candidates.find_each(&:mark_stale!) + + new_sync = syncs.create!( + parent: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + + SyncJob.perform_later(new_sync) + end end def perform_sync(sync) diff --git a/app/models/family.rb b/app/models/family.rb index 0cfb8b89..cd068cae 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -40,7 +40,7 @@ class Family < ApplicationRecord Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'") .joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") .where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id) - .incomplete + .visible .exists? end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 4aae91ca..2ba10599 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -56,7 +56,7 @@ class PlaidItem < ApplicationRecord Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") .joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id") .where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id) - .incomplete + .visible .exists? end diff --git a/app/models/sync.rb b/app/models/sync.rb index f783bbc2..456a4b01 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -1,4 +1,11 @@ class Sync < ApplicationRecord + # We run a cron that marks any syncs that have been running > 2 hours as "stale" + # Syncs often become stale when new code is deployed and the worker restarts + STALE_AFTER = 2.hours + + # The max time that a sync will show in the UI (after 5 minutes) + VISIBLE_FOR = 5.minutes + include AASM Error = Class.new(StandardError) @@ -9,7 +16,11 @@ class Sync < ApplicationRecord has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy scope :ordered, -> { order(created_at: :desc) } - scope :incomplete, -> { where(status: [ :pending, :syncing ]) } + scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } + scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } + + # In-flight records that have exceeded the allowed runtime + scope :stale_candidates, -> { incomplete.where("syncs.created_at < ?", STALE_AFTER.ago) } validate :window_valid @@ -19,6 +30,7 @@ class Sync < ApplicationRecord state :syncing state :completed state :failed + state :stale after_all_transitions :log_status_change @@ -33,6 +45,17 @@ class Sync < ApplicationRecord event :fail do transitions from: :syncing, to: :failed end + + # Marks a sync that never completed within the expected time window + event :mark_stale do + transitions from: %i[pending syncing], to: :stale + end + end + + class << self + def clean + stale_candidates.find_each(&:mark_stale!) + end end def perform diff --git a/config/schedule.yml b/config/schedule.yml index 28078e4d..75b709a6 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -6,3 +6,9 @@ import_market_data: args: mode: "full" clear_cache: false + +clean_syncs: + cron: "0 * * * *" # every hour + class: "SyncCleanerJob" + queue: "scheduled" + description: "Cleans up stale syncs" diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index cbab9ed3..12e410c6 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -167,4 +167,25 @@ class SyncTest < ActiveSupport::TestCase assert_equal "failed", family_sync.reload.status assert_equal "completed", account_sync.reload.status end + + test "clean marks stale incomplete rows" do + stale_pending = Sync.create!( + syncable: accounts(:depository), + status: :pending, + created_at: 3.hours.ago + ) + + stale_syncing = Sync.create!( + syncable: accounts(:depository), + status: :syncing, + created_at: 3.hours.ago, + pending_at: 3.hours.ago, + syncing_at: 2.hours.ago + ) + + Sync.clean + + assert_equal "stale", stale_pending.reload.status + assert_equal "stale", stale_syncing.reload.status + end end From 29a8ac9d8a5c6574fa2e07c87f09c2cb233ef7ed Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 18 May 2025 10:19:15 -0400 Subject: [PATCH 12/28] Tweak exception logging, sync stale behavior --- app/models/concerns/syncable.rb | 9 +++++---- app/models/provider/synth.rb | 16 ++++++++++------ app/models/security/price/importer.rb | 22 ++++++++++++++++------ app/models/sync.rb | 9 +++------ test/interfaces/syncable_interface_test.rb | 8 ++++++++ test/models/sync_test.rb | 8 ++++---- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 9b5e09e4..6b0ba684 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -11,17 +11,18 @@ module Syncable def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil) Sync.transaction do - # Expire any previous in-flight syncs for this record that exceeded the - # global staleness window. - syncs.stale_candidates.find_each(&:mark_stale!) + # Since we're scheduling a new sync, mark old syncs for this syncable as stale + self.syncs.incomplete.find_each(&:mark_stale!) - new_sync = syncs.create!( + new_sync = self.syncs.create!( parent: parent_sync, window_start_date: window_start_date, window_end_date: window_end_date ) SyncJob.perform_later(new_sync) + + new_sync end end diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index fee3a236..8d76fc72 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -71,9 +71,11 @@ class Provider::Synth < Provider rate = rate.dig("rates", to) if date.nil? || rate.nil? - message = "#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}" - Rails.logger.warn(message) - Sentry.capture_exception(InvalidExchangeRateError.new(message), level: :warning) + Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}") + Sentry.capture_exception(InvalidExchangeRateError.new("#{self.class.name} returned invalid rate data"), level: :warning) do |scope| + scope.set_context("rate", { from: from, to: to, date: date }) + end + next end @@ -162,9 +164,11 @@ class Provider::Synth < Provider price = price.dig("close") || price.dig("open") if date.nil? || price.nil? - message = "#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}" - Rails.logger.warn(message) - Sentry.capture_exception(InvalidSecurityPriceError.new(message), level: :warning) + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}") + Sentry.capture_exception(InvalidSecurityPriceError.new("#{self.class.name} returned invalid security price data"), level: :warning) do |scope| + scope.set_context("security", { symbol: symbol, date: date }) + end + next end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index 4e6bee2f..6143f22a 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -26,9 +26,16 @@ class Security::Price::Importer prev_price_value = start_price_value unless prev_price_value.present? - error = MissingStartPriceError.new("Could not find a start price for #{security.ticker} on or before #{start_date}") - Rails.logger.error(error.message) - Sentry.capture_exception(error) + Rails.logger.error("Could not find a start price for #{security.ticker} on or before #{start_date}") + + Sentry.capture_exception(MissingStartPriceError.new("Could not determine start price for ticker")) do |scope| + scope.set_tags(security_id: security.id) + scope.set_context("security", { + id: security.id, + start_date: start_date + }) + end + return 0 end @@ -75,9 +82,12 @@ class Security::Price::Importer if response.success? response.data.index_by(&:date) else - msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}" - Rails.logger.warn(msg) - Sentry.capture_exception(MissingSecurityPriceError.new(msg), level: :warning) + Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}") + Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope| + scope.set_tags(security_id: security.id) + scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) + end + {} end end diff --git a/app/models/sync.rb b/app/models/sync.rb index 456a4b01..aa40b313 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -1,7 +1,7 @@ class Sync < ApplicationRecord - # We run a cron that marks any syncs that have been running > 2 hours as "stale" + # We run a cron that marks any syncs that have not been resolved in 24 hours as "stale" # Syncs often become stale when new code is deployed and the worker restarts - STALE_AFTER = 2.hours + STALE_AFTER = 24.hours # The max time that a sync will show in the UI (after 5 minutes) VISIBLE_FOR = 5.minutes @@ -19,9 +19,6 @@ class Sync < ApplicationRecord scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } - # In-flight records that have exceeded the allowed runtime - scope :stale_candidates, -> { incomplete.where("syncs.created_at < ?", STALE_AFTER.ago) } - validate :window_valid # Sync state machine @@ -54,7 +51,7 @@ class Sync < ApplicationRecord class << self def clean - stale_candidates.find_each(&:mark_stale!) + incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!) end end diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb index a9f1fa7d..95f6789d 100644 --- a/test/interfaces/syncable_interface_test.rb +++ b/test/interfaces/syncable_interface_test.rb @@ -17,4 +17,12 @@ module SyncableInterfaceTest @syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once @syncable.perform_sync(mock_sync) end + + test "any prior syncs for the same syncable entity are marked stale when new sync is requested" do + stale_sync = @syncable.sync_later + new_sync = @syncable.sync_later + + assert_equal "stale", stale_sync.reload.status + assert_equal "pending", new_sync.reload.status + end end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 12e410c6..09266bb5 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -172,15 +172,15 @@ class SyncTest < ActiveSupport::TestCase stale_pending = Sync.create!( syncable: accounts(:depository), status: :pending, - created_at: 3.hours.ago + created_at: 25.hours.ago ) stale_syncing = Sync.create!( syncable: accounts(:depository), status: :syncing, - created_at: 3.hours.ago, - pending_at: 3.hours.ago, - syncing_at: 2.hours.ago + created_at: 25.hours.ago, + pending_at: 24.hours.ago, + syncing_at: 23.hours.ago ) Sync.clean From 74c7b0941d161b17c286af7004b375c61969b289 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 18 May 2025 10:27:46 -0400 Subject: [PATCH 13/28] More exception logging tweaks --- app/models/security/provided.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 2fdcc607..221acde5 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -1,6 +1,8 @@ module Security::Provided extend ActiveSupport::Concern + SecurityInfoMissingError = Class.new(StandardError) + class_methods do def provider registry = Provider::Registry.for_concept(:securities) @@ -70,9 +72,11 @@ module Security::Provided logo_url: response.data.logo_url, ) else - err = StandardError.new("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") - Rails.logger.warn(err.message) - Sentry.capture_exception(err, level: :warning) + Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") + Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope| + scope.set_tags(security_id: self.id) + scope.set_context(provider_error: response.error.message) + end end end From f82f77466a9780766ae9be6a4bd032b3f0e72070 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 18 May 2025 10:52:35 -0400 Subject: [PATCH 14/28] Fix Sentry context for security details exception --- app/jobs/fetch_security_info_job.rb | 21 --------------------- app/models/security/provided.rb | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 app/jobs/fetch_security_info_job.rb diff --git a/app/jobs/fetch_security_info_job.rb b/app/jobs/fetch_security_info_job.rb deleted file mode 100644 index e789222f..00000000 --- a/app/jobs/fetch_security_info_job.rb +++ /dev/null @@ -1,21 +0,0 @@ -class FetchSecurityInfoJob < ApplicationJob - queue_as :low_priority - - def perform(security_id) - return unless Security.provider.present? - - security = Security.find(security_id) - - params = { - ticker: security.ticker - } - params[:mic_code] = security.exchange_mic if security.exchange_mic.present? - params[:operating_mic] = security.exchange_operating_mic if security.exchange_operating_mic.present? - - security_info_response = Security.provider.fetch_security_info(**params) - - security.update( - name: security_info_response.info.dig("name") - ) - end -end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 221acde5..7927d6e6 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -75,7 +75,7 @@ module Security::Provided Rails.logger.warn("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}") Sentry.capture_exception(SecurityInfoMissingError.new("Failed to get security info"), level: :warning) do |scope| scope.set_tags(security_id: self.id) - scope.set_context(provider_error: response.error.message) + scope.set_context("security", { id: self.id, provider_error: response.error.message }) end end end From e26e5c5aec7b266496445a4027b27b443d6ccdaf Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 18 May 2025 15:02:51 -0400 Subject: [PATCH 15/28] Auto sync preference, max limit on account CSV imports (#2259) * Auto sync preference, max limit on account CSV imports * MaxRowCountExceededError --- app/controllers/concerns/auto_sync.rb | 1 + app/controllers/imports_controller.rb | 2 ++ app/models/account_import.rb | 4 ++++ app/models/import.rb | 13 +++++++++++++ ...0518181619_add_auto_sync_preference_to_family.rb | 5 +++++ db/schema.rb | 3 ++- test/controllers/concerns/auto_sync_test.rb | 8 ++++++++ 7 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250518181619_add_auto_sync_preference_to_family.rb diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index e6ced672..15cdc557 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -13,6 +13,7 @@ module AutoSync def family_needs_auto_sync? return false unless Current.family&.accounts&.active&.any? return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current + return false unless Current.family.auto_sync_on_login Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}" diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index c1b51c23..20e5f9c4 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -5,6 +5,8 @@ class ImportsController < ApplicationController @import.publish_later redirect_to import_path(@import), notice: "Your import has started in the background." + rescue Import::MaxRowCountExceededError + redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}." end def index diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 96fdfd47..aa4c6dfe 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -54,4 +54,8 @@ class AccountImport < Import CSV.parse(template, headers: true) end + + def max_row_count + 50 + end end diff --git a/app/models/import.rb b/app/models/import.rb index e96d1fc1..b0a02ea0 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -1,4 +1,6 @@ class Import < ApplicationRecord + MaxRowCountExceededError = Class.new(StandardError) + TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze @@ -52,6 +54,7 @@ class Import < ApplicationRecord end def publish_later + raise MaxRowCountExceededError if row_count_exceeded? raise "Import is not publishable" unless publishable? update! status: :importing @@ -60,6 +63,8 @@ class Import < ApplicationRecord end def publish + raise MaxRowCountExceededError if row_count_exceeded? + import! family.sync_later @@ -220,7 +225,15 @@ class Import < ApplicationRecord ) end + def max_row_count + 10000 + end + private + def row_count_exceeded? + rows.count > max_row_count + end + def import! # no-op, subclasses can implement for customization of algorithm end diff --git a/db/migrate/20250518181619_add_auto_sync_preference_to_family.rb b/db/migrate/20250518181619_add_auto_sync_preference_to_family.rb new file mode 100644 index 00000000..80e1cd9f --- /dev/null +++ b/db/migrate/20250518181619_add_auto_sync_preference_to_family.rb @@ -0,0 +1,5 @@ +class AddAutoSyncPreferenceToFamily < ActiveRecord::Migration[7.2] + def change + add_column :families, :auto_sync_on_login, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 5b9426c7..cdb863c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do +ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -227,6 +227,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do t.string "timezone" t.boolean "data_enrichment_enabled", default: false t.boolean "early_access", default: false + t.boolean "auto_sync_on_login", default: true, null: false end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/concerns/auto_sync_test.rb b/test/controllers/concerns/auto_sync_test.rb index a388456b..462850f8 100644 --- a/test/controllers/concerns/auto_sync_test.rb +++ b/test/controllers/concerns/auto_sync_test.rb @@ -38,4 +38,12 @@ class AutoSyncTest < ActionDispatch::IntegrationTest get root_path end end + + test "does not auto-sync if preference is disabled" do + @family.update!(auto_sync_on_login: false) + + assert_no_difference "Sync.count" do + get root_path + end + end end From 6f68d66edaa667335d032730822160e1d0fa1922 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 13:31:56 -0400 Subject: [PATCH 16/28] Bump bootsnap from 1.18.4 to 1.18.6 (#2266) Bumps [bootsnap](https://github.com/Shopify/bootsnap) from 1.18.4 to 1.18.6. - [Changelog](https://github.com/Shopify/bootsnap/blob/main/CHANGELOG.md) - [Commits](https://github.com/Shopify/bootsnap/compare/v1.18.4...v1.18.6) --- updated-dependencies: - dependency-name: bootsnap dependency-version: 1.18.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a8c86724..aff3663b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,7 +120,7 @@ GEM smart_properties bigdecimal (3.1.9) bindex (0.8.1) - bootsnap (1.18.4) + bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.0.2) racc From e569ad0a8c65f4179b9f9b191918bee0c555a8a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 13:32:06 -0400 Subject: [PATCH 17/28] Bump sentry-sidekiq from 5.23.0 to 5.24.0 (#2265) Bumps [sentry-sidekiq](https://github.com/getsentry/sentry-ruby) from 5.23.0 to 5.24.0. - [Release notes](https://github.com/getsentry/sentry-ruby/releases) - [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-ruby/compare/5.23.0...5.24.0) --- updated-dependencies: - dependency-name: sentry-sidekiq dependency-version: 5.24.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index aff3663b..0ac59ec9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -243,7 +243,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.11.3) + json (2.12.0) jwt (2.10.1) base64 language_server-protocol (3.17.0.4) @@ -361,7 +361,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.13) + rack (3.1.15) rack-mini-profiler (3.3.1) rack (>= 1.2.0) rack-session (2.1.0) @@ -485,14 +485,14 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sentry-rails (5.23.0) + sentry-rails (5.24.0) railties (>= 5.0) - sentry-ruby (~> 5.23.0) - sentry-ruby (5.23.0) + sentry-ruby (~> 5.24.0) + sentry-ruby (5.24.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.23.0) - sentry-ruby (~> 5.23.0) + sentry-sidekiq (5.24.0) + sentry-ruby (~> 5.24.0) sidekiq (>= 3.0) sidekiq (8.0.3) connection_pool (>= 2.5.0) From 1b4577e21e63f4f2edf72aa0ab71665c2a1f44e6 Mon Sep 17 00:00:00 2001 From: Alex Hatzenbuhler Date: Mon, 19 May 2025 12:34:02 -0500 Subject: [PATCH 18/28] Fix subconditions and condition group form (#2256) --- app/javascript/controllers/rule/conditions_controller.js | 4 ++-- app/views/rule/conditions/_condition_group.html.erb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js index d0c12941..1a20d00d 100644 --- a/app/javascript/controllers/rule/conditions_controller.js +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -13,7 +13,7 @@ export default class extends Controller { addSubCondition() { const html = this.subConditionTemplateTarget.innerHTML.replaceAll( - "IDX_PLACEHOLDER", + "IDX_CHILD_PLACEHOLDER", this.#uniqueKey(), ); @@ -110,6 +110,6 @@ export default class extends Controller { } #uniqueKey() { - return Math.random().toString(36).substring(2, 15); + return Date.now(); } } diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb index e04a09f7..77383833 100644 --- a/app/views/rule/conditions/_condition_group.html.erb +++ b/app/views/rule/conditions/_condition_group.html.erb @@ -28,13 +28,13 @@ <%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
    - <%= form.fields_for :sub_conditions do |scf| %> + <%= form.fields_for :sub_conditions, condition.sub_conditions.select(&:persisted?) do |scf| %> <%= render "rule/conditions/condition", form: scf, show_prefix: false %> <% end %>
From efdd03cfe7ebd79e34c342e424f76ebeb727fc00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 13:34:11 -0400 Subject: [PATCH 19/28] Bump vernier from 1.7.0 to 1.7.1 (#2260) Bumps [vernier](https://github.com/jhawthorn/vernier) from 1.7.0 to 1.7.1. - [Release notes](https://github.com/jhawthorn/vernier/releases) - [Commits](https://github.com/jhawthorn/vernier/compare/v1.7.0...v1.7.1) --- updated-dependencies: - dependency-name: vernier dependency-version: 1.7.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0ac59ec9..1e63e33b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -546,7 +546,7 @@ GEM useragent (0.16.11) vcr (6.3.1) base64 - vernier (1.7.0) + vernier (1.7.1) view_component (3.22.0) activesupport (>= 5.2.0, < 8.1) concurrent-ruby (= 1.3.4) From 7e7ae312164e7f57d1da77d2d52e39bfffba4e40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 13:39:41 -0400 Subject: [PATCH 20/28] Bump sidekiq-cron from 2.2.0 to 2.3.0 (#2261) Bumps [sidekiq-cron](https://github.com/ondrejbartas/sidekiq-cron) from 2.2.0 to 2.3.0. - [Release notes](https://github.com/ondrejbartas/sidekiq-cron/releases) - [Changelog](https://github.com/sidekiq-cron/sidekiq-cron/blob/master/CHANGELOG.md) - [Commits](https://github.com/ondrejbartas/sidekiq-cron/compare/v2.2.0...v2.3.0) --- updated-dependencies: - dependency-name: sidekiq-cron dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1e63e33b..0b4ee9f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -500,7 +500,7 @@ GEM logger (>= 1.6.2) rack (>= 3.1.0) redis-client (>= 0.23.2) - sidekiq-cron (2.2.0) + sidekiq-cron (2.3.0) cronex (>= 0.13.0) fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) From a262a749fea68398ff020c679f866f371117c840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 13:39:48 -0400 Subject: [PATCH 21/28] Bump ruby-lsp-rails from 0.4.2 to 0.4.3 (#2262) Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.4.2 to 0.4.3. - [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases) - [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.4.2...v0.4.3) --- updated-dependencies: - dependency-name: ruby-lsp-rails dependency-version: 0.4.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0b4ee9f4..28ccdd3e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,7 +246,7 @@ GEM json (2.12.0) jwt (2.10.1) base64 - language_server-protocol (3.17.0.4) + language_server-protocol (3.17.0.5) launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -411,7 +411,7 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.9.2) + rbs (3.9.4) logger rdoc (6.13.1) psych (>= 4.0.0) @@ -458,13 +458,13 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.23.16) + ruby-lsp (0.23.20) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.4.2) - ruby-lsp (>= 0.23.16, < 0.24.0) + ruby-lsp-rails (0.4.3) + ruby-lsp (>= 0.23.18, < 0.24.0) ruby-openai (8.1.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -514,7 +514,7 @@ GEM skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) - sorbet-runtime (0.5.12060) + sorbet-runtime (0.5.12115) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) From ab5bce3462b55fe4cb098f72489ffa0e5d675823 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 19 May 2025 15:19:41 -0400 Subject: [PATCH 22/28] Fix provider guards for start price --- app/models/exchange_rate/importer.rb | 4 ++-- app/models/security/price/importer.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/exchange_rate/importer.rb b/app/models/exchange_rate/importer.rb index 133106bc..0975f2ed 100644 --- a/app/models/exchange_rate/importer.rb +++ b/app/models/exchange_rate/importer.rb @@ -18,8 +18,8 @@ class ExchangeRate::Importer return end - if clear_cache && provider_rates.empty? - Rails.logger.warn("Could not clear cache for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates") + if provider_rates.empty? + Rails.logger.warn("Could not fetch rates for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates") return end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index 6143f22a..bcee3762 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -18,8 +18,8 @@ class Security::Price::Importer return 0 end - if clear_cache && provider_prices.empty? - Rails.logger.warn("Could not clear cache for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices") + if provider_prices.empty? + Rails.logger.warn("Could not fetch prices for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices") return 0 end From 137219c121fdd31bb171d307ec364524c4d6f71a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 19 May 2025 16:39:31 -0400 Subject: [PATCH 23/28] Fix attribute locking namespace conflict, duplicate syncs --- app/controllers/transactions_controller.rb | 4 ++-- app/models/concerns/enrichable.rb | 6 ++--- app/models/concerns/syncable.rb | 26 ++++++++++++++------- app/models/entry.rb | 2 +- app/models/family/auto_categorizer.rb | 2 +- app/models/family/auto_merchant_detector.rb | 2 +- app/models/sync.rb | 23 ++++++++++++++++++ test/controllers/concerns/auto_sync_test.rb | 4 ++-- test/interfaces/syncable_interface_test.rb | 18 ++++++++++---- test/models/rule/action_test.rb | 8 +++---- test/models/sync_test.rb | 20 ++++++++++++++++ 11 files changed, 87 insertions(+), 28 deletions(-) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 4d47c06b..e5382e73 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -61,7 +61,7 @@ class TransactionsController < ApplicationController if @entry.save @entry.sync_account_later @entry.lock_saved_attributes! - @entry.transaction.lock!(:tag_ids) if @entry.transaction.tags.any? + @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? flash[:notice] = "Transaction created" @@ -88,7 +88,7 @@ class TransactionsController < ApplicationController @entry.sync_account_later @entry.lock_saved_attributes! - @entry.transaction.lock!(:tag_ids) if @entry.transaction.tags.any? + @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index e5804786..febc358f 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -42,17 +42,17 @@ module Enrichable !locked?(attr) end - def lock!(attr) + def lock_attr!(attr) update!(locked_attributes: locked_attributes.merge(attr.to_s => Time.current)) end - def unlock!(attr) + def unlock_attr!(attr) update!(locked_attributes: locked_attributes.except(attr.to_s)) end def lock_saved_attributes! saved_changes.keys.reject { |attr| ignored_enrichable_attributes.include?(attr) }.each do |attr| - lock!(attr) + lock_attr!(attr) end end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 6b0ba684..72556bf7 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -9,20 +9,28 @@ module Syncable raise NotImplementedError, "Subclasses must implement the syncing? method" end + # Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable, + # we do not create a new sync, and attempt to expand the sync window if needed. def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil) Sync.transaction do - # Since we're scheduling a new sync, mark old syncs for this syncable as stale - self.syncs.incomplete.find_each(&:mark_stale!) + with_lock do + sync = self.syncs.incomplete.first - new_sync = self.syncs.create!( - parent: parent_sync, - window_start_date: window_start_date, - window_end_date: window_end_date - ) + if sync + Rails.logger.info("There is an existing sync, expanding window if needed (#{sync.id})") + sync.expand_window_if_needed(window_start_date, window_end_date) + else + sync = self.syncs.create!( + parent: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) - SyncJob.perform_later(new_sync) + SyncJob.perform_later(sync) + end - new_sync + sync + end end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 36f61c29..5b14987a 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -85,7 +85,7 @@ class Entry < ApplicationRecord entry.update! bulk_attributes entry.lock_saved_attributes! - entry.entryable.lock!(:tag_ids) if entry.transaction? && entry.transaction.tags.any? + entry.entryable.lock_attr!(:tag_ids) if entry.transaction? && entry.transaction.tags.any? end end diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index c35aa3b9..038be1d8 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -27,7 +27,7 @@ class Family::AutoCategorizer end scope.each do |transaction| - transaction.lock!(:category_id) + transaction.lock_attr!(:category_id) auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index 4b791e7a..39ddcd18 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -27,7 +27,7 @@ class Family::AutoMerchantDetector end scope.each do |transaction| - transaction.lock!(:merchant_id) + transaction.lock_attr!(:merchant_id) auto_detection = result.data.find { |c| c.transaction_id == transaction.id } diff --git a/app/models/sync.rb b/app/models/sync.rb index aa40b313..08b2f842 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -95,6 +95,29 @@ class Sync < ApplicationRecord parent&.finalize_if_all_children_finalized end + # If a sync is pending, we can adjust the window if new syncs are created with a wider window. + def expand_window_if_needed(new_window_start_date, new_window_end_date) + return unless pending? + return if self.window_start_date.nil? && self.window_end_date.nil? # already as wide as possible + + earliest_start_date = if self.window_start_date && new_window_start_date + [self.window_start_date, new_window_start_date].min + else + nil + end + + latest_end_date = if self.window_end_date && new_window_end_date + [self.window_end_date, new_window_end_date].max + else + nil + end + + update( + window_start_date: earliest_start_date, + window_end_date: latest_end_date + ) + end + private def log_status_change Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})") diff --git a/test/controllers/concerns/auto_sync_test.rb b/test/controllers/concerns/auto_sync_test.rb index 462850f8..0ae19ab1 100644 --- a/test/controllers/concerns/auto_sync_test.rb +++ b/test/controllers/concerns/auto_sync_test.rb @@ -20,7 +20,7 @@ class AutoSyncTest < ActionDispatch::IntegrationTest travel_to Time.current.beginning_of_day last_sync_datetime = 1.hour.ago - Sync.create!(syncable: @family, created_at: last_sync_datetime) + Sync.create!(syncable: @family, created_at: last_sync_datetime, status: "completed") assert_difference "Sync.count", 1 do get root_path @@ -32,7 +32,7 @@ class AutoSyncTest < ActionDispatch::IntegrationTest last_created_sync_at = 23.hours.ago - Sync.create!(syncable: @family, created_at: last_created_sync_at) + Sync.create!(syncable: @family, created_at: last_created_sync_at, status: "completed") assert_no_difference "Sync.count" do get root_path diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb index 95f6789d..df142052 100644 --- a/test/interfaces/syncable_interface_test.rb +++ b/test/interfaces/syncable_interface_test.rb @@ -18,11 +18,19 @@ module SyncableInterfaceTest @syncable.perform_sync(mock_sync) end - test "any prior syncs for the same syncable entity are marked stale when new sync is requested" do - stale_sync = @syncable.sync_later - new_sync = @syncable.sync_later + test "second sync request widens existing pending window" do + later_start = 2.days.ago.to_date + first_sync = @syncable.sync_later(window_start_date: later_start, window_end_date: later_start) - assert_equal "stale", stale_sync.reload.status - assert_equal "pending", new_sync.reload.status + earlier_start = 5.days.ago.to_date + wider_end = Date.current + + assert_no_difference "@syncable.syncs.count" do + @syncable.sync_later(window_start_date: earlier_start, window_end_date: wider_end) + end + + first_sync.reload + assert_equal earlier_start, first_sync.window_start_date + assert_equal wider_end, first_sync.window_end_date end end diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb index 624849fc..f71bb2cb 100644 --- a/test/models/rule/action_test.rb +++ b/test/models/rule/action_test.rb @@ -21,7 +21,7 @@ class Rule::ActionTest < ActiveSupport::TestCase test "set_transaction_category" do # Does not modify transactions that are locked (user edited them) - @txn1.lock!(:category_id) + @txn1.lock_attr!(:category_id) action = Rule::Action.new( rule: @transaction_rule, @@ -42,7 +42,7 @@ class Rule::ActionTest < ActiveSupport::TestCase tag = @family.tags.create!(name: "Rule test tag") # Does not modify transactions that are locked (user edited them) - @txn1.lock!(:tag_ids) + @txn1.lock_attr!(:tag_ids) action = Rule::Action.new( rule: @transaction_rule, @@ -63,7 +63,7 @@ class Rule::ActionTest < ActiveSupport::TestCase merchant = @family.merchants.create!(name: "Rule test merchant") # Does not modify transactions that are locked (user edited them) - @txn1.lock!(:merchant_id) + @txn1.lock_attr!(:merchant_id) action = Rule::Action.new( rule: @transaction_rule, @@ -84,7 +84,7 @@ class Rule::ActionTest < ActiveSupport::TestCase new_name = "Renamed Transaction" # Does not modify transactions that are locked (user edited them) - @txn1.lock!(:name) + @txn1.lock_attr!(:name) action = Rule::Action.new( rule: @transaction_rule, diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 09266bb5..05765ea0 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -188,4 +188,24 @@ class SyncTest < ActiveSupport::TestCase assert_equal "stale", stale_pending.reload.status assert_equal "stale", stale_syncing.reload.status end + + test "expand_window_if_needed widens start and end dates on a pending sync" do + initial_start = 1.day.ago.to_date + initial_end = 1.day.ago.to_date + + sync = Sync.create!( + syncable: accounts(:depository), + window_start_date: initial_start, + window_end_date: initial_end + ) + + new_start = 5.days.ago.to_date + new_end = Date.current + + sync.expand_window_if_needed(new_start, new_end) + sync.reload + + assert_equal new_start, sync.window_start_date + assert_equal new_end, sync.window_end_date + end end From dd605a577e6cf3334b37ce5dfa47f5f02f8ae6f2 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 20 May 2025 09:09:10 -0400 Subject: [PATCH 24/28] Bump ruby to 3.4.4 --- .devcontainer/Dockerfile | 2 +- .ruby-version | 2 +- Dockerfile | 2 +- Gemfile | 2 +- Gemfile.lock | 54 +++++++++++++++++++++------------------- app/models/sync.rb | 4 +-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 949a9b9d..cc56694e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG RUBY_VERSION=3.4.1 +ARG RUBY_VERSION=3.4.4 FROM ruby:${RUBY_VERSION}-slim-bullseye RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ diff --git a/.ruby-version b/.ruby-version index 47b322c9..f9892605 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.1 +3.4.4 diff --git a/Dockerfile b/Dockerfile index d98833b3..81805bee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.1 +ARG RUBY_VERSION=3.4.4 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile b/Gemfile index 3ad4f0a8..c06ca145 100644 --- a/Gemfile +++ b/Gemfile @@ -24,7 +24,6 @@ gem "stimulus-rails" gem "turbo-rails" gem "view_component" gem "lookbook", ">= 2.3.7" - gem "hotwire_combobox" # Background Jobs @@ -45,6 +44,7 @@ gem "aws-sdk-s3", "~> 1.177.0", require: false gem "image_processing", ">= 1.2" # Other +gem "ostruct" gem "bcrypt", "~> 3.1" gem "jwt" gem "faraday" diff --git a/Gemfile.lock b/Gemfile.lock index 28ccdd3e..6596db18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,15 +90,15 @@ GEM activesupport ast (2.4.3) aws-eventstream (1.3.2) - aws-partitions (1.1093.0) - aws-sdk-core (3.222.3) + aws-partitions (1.1105.0) + aws-sdk-core (3.224.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.99.0) + aws-sdk-kms (1.101.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.177.0) @@ -160,6 +160,7 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.1) + erb (5.0.1) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -269,7 +270,7 @@ GEM logtail (~> 0.1, >= 0.1.14) logtail-rack (~> 0.1) railties (>= 5.0.0) - loofah (2.24.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lookbook (2.3.9) @@ -303,7 +304,7 @@ GEM multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.7) + net-imap (0.5.8) date net-protocol net-pop (0.1.2) @@ -332,13 +333,14 @@ GEM octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) + ostruct (0.6.1) pagy (9.3.4) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) racc pg (1.5.9) - plaid (38.0.0) + plaid (39.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) platform_agent (1.0.1) @@ -353,10 +355,10 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.3) + psych (5.2.6) date stringio - public_suffix (6.0.1) + public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) raabro (1.4.0) @@ -364,7 +366,7 @@ GEM rack (3.1.15) rack-mini-profiler (3.3.1) rack (>= 1.2.0) - rack-session (2.1.0) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -413,7 +415,8 @@ GEM ffi (~> 1.0) rbs (3.9.4) logger - rdoc (6.13.1) + rdoc (6.14.0) + erb psych (>= 4.0.0) redcarpet (3.6.1) redis (5.4.0) @@ -430,7 +433,7 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) - rubocop (1.75.4) + rubocop (1.75.6) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -448,12 +451,12 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.31.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) rubocop-rails-omakase (1.1.0) rubocop (>= 1.72) rubocop-performance (>= 1.24) @@ -514,21 +517,21 @@ GEM skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) - sorbet-runtime (0.5.12115) + sorbet-runtime (0.5.12117) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) stripe (15.1.0) - tailwindcss-rails (4.2.2) + tailwindcss-rails (4.2.3) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.4) - tailwindcss-ruby (4.1.4-aarch64-linux-gnu) - tailwindcss-ruby (4.1.4-aarch64-linux-musl) - tailwindcss-ruby (4.1.4-arm64-darwin) - tailwindcss-ruby (4.1.4-x86_64-darwin) - tailwindcss-ruby (4.1.4-x86_64-linux-gnu) - tailwindcss-ruby (4.1.4-x86_64-linux-musl) + tailwindcss-ruby (4.1.7) + tailwindcss-ruby (4.1.7-aarch64-linux-gnu) + tailwindcss-ruby (4.1.7-aarch64-linux-musl) + tailwindcss-ruby (4.1.7-arm64-darwin) + tailwindcss-ruby (4.1.7-x86_64-darwin) + tailwindcss-ruby (4.1.7-x86_64-linux-gnu) + tailwindcss-ruby (4.1.7-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) @@ -568,7 +571,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.37) - zeitwerk (2.7.2) + zeitwerk (2.7.3) PLATFORMS aarch64-linux-gnu @@ -614,6 +617,7 @@ DEPENDENCIES lucide-rails! mocha octokit + ostruct pagy pg (~> 1.5) plaid @@ -649,7 +653,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.4p34 BUNDLED WITH - 2.6.3 + 2.6.9 diff --git a/app/models/sync.rb b/app/models/sync.rb index 08b2f842..be4bb8c3 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -101,13 +101,13 @@ class Sync < ApplicationRecord return if self.window_start_date.nil? && self.window_end_date.nil? # already as wide as possible earliest_start_date = if self.window_start_date && new_window_start_date - [self.window_start_date, new_window_start_date].min + [ self.window_start_date, new_window_start_date ].min else nil end latest_end_date = if self.window_end_date && new_window_end_date - [self.window_end_date, new_window_end_date].max + [ self.window_end_date, new_window_end_date ].max else nil end From 94a807c3c9e0b2984ca0acfe58f8d2ee66c4d59d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 20 May 2025 11:33:35 -0400 Subject: [PATCH 25/28] Encapsulate enrichment actions, add tests --- app/models/concerns/enrichable.rb | 37 ++++++--- app/models/family/auto_categorizer.rb | 18 ++--- app/models/family/auto_merchant_detector.rb | 18 ++--- app/models/plaid_item.rb | 14 ++-- .../set_transaction_category.rb | 14 ++-- .../set_transaction_merchant.rb | 13 ++- .../action_executor/set_transaction_name.rb | 13 ++- .../action_executor/set_transaction_tags.rb | 14 ++-- test/models/concerns/enrichable_test.rb | 79 +++++++++++++++++++ 9 files changed, 149 insertions(+), 71 deletions(-) create mode 100644 test/models/concerns/enrichable_test.rb diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index febc358f..4c373b01 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -22,16 +22,23 @@ module Enrichable } end - def log_enrichment!(attribute_name:, attribute_value:, source:, metadata: {}) - de = DataEnrichment.find_or_create_by!( - enrichable: self, - attribute_name: attribute_name, - source: source, - ) + # Convenience method for a single attribute + def enrich_attribute(attr, value, source:, metadata: {}) + enrich_attributes({ attr => value }, source:, metadata:) + end - de.value = attribute_value - de.metadata = metadata - de.save! + # Enriches all attributes that haven't been locked yet + def enrich_attributes(attrs, source:, metadata: {}) + enrichable_attrs = Array(attrs).reject { |k, _v| locked?(k) } + + ActiveRecord::Base.transaction do + enrichable_attrs.each do |attr, value| + self.send("#{attr}=", value) + log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata) + end + + save + end end def locked?(attr) @@ -57,6 +64,18 @@ module Enrichable end private + def log_enrichment(attribute_name:, attribute_value:, source:, metadata: {}) + de = DataEnrichment.find_or_create_by( + enrichable: self, + attribute_name: attribute_name, + source: source, + ) + + de.value = attribute_value + de.metadata = metadata + de.save + end + def ignored_enrichable_attributes %w[id updated_at created_at] end diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 038be1d8..25fde493 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -27,23 +27,19 @@ class Family::AutoCategorizer end scope.each do |transaction| - transaction.lock_attr!(:category_id) - auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } category_id = user_categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) if category_id.present? - Family.transaction do - transaction.log_enrichment!( - attribute_name: "category_id", - attribute_value: category_id, - source: "ai", - ) - - transaction.update!(category_id: category_id) - end + transaction.enrich_attribute( + :category_id, + category_id, + source: "ai" + ) end + + transaction.lock_attr!(:category_id) end end diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index 39ddcd18..39e58a3a 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -27,8 +27,6 @@ class Family::AutoMerchantDetector end scope.each do |transaction| - transaction.lock_attr!(:merchant_id) - auto_detection = result.data.find { |c| c.transaction_id == transaction.id } merchant_id = user_merchants_input.find { |m| m[:name] == auto_detection&.business_name }&.dig(:id) @@ -46,16 +44,16 @@ class Family::AutoMerchantDetector merchant_id = merchant_id || ai_provider_merchant&.id if merchant_id.present? - Family.transaction do - transaction.log_enrichment!( - attribute_name: "merchant_id", - attribute_value: merchant_id, - source: "ai", - ) + transaction.enrich_attribute( + :merchant_id, + merchant_id, + source: "ai" + ) - transaction.update!(merchant_id: merchant_id) - end end + + # We lock the attribute so that this Rule doesn't try to run again + transaction.lock_attr!(:merchant_id) end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 2ba10599..e693e69a 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -77,14 +77,14 @@ class PlaidItem < ApplicationRecord category = alias_matcher.match(transaction.plaid_category_detailed) if category.present? - PlaidItem.transaction do - transaction.log_enrichment!( - attribute_name: "category_id", - attribute_value: category.id, - source: "plaid" - ) - transaction.set_category!(category) + # Matcher could either return a string or a Category object + user_category = if category.is_a?(String) + family.categories.find_or_create_by!(name: category) + else + category end + + transaction.enrich_attribute(:category_id, user_category.id, source: "plaid") end end end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb index ef186d96..6360e45a 100644 --- a/app/models/rule/action_executor/set_transaction_category.rb +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -17,15 +17,11 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor end scope.each do |txn| - Rule.transaction do - txn.log_enrichment!( - attribute_name: "category_id", - attribute_value: category.id, - source: "rule" - ) - - txn.update!(category: category) - end + txn.enrich_attribute( + :category_id, + category.id, + source: "rule" + ) end end end diff --git a/app/models/rule/action_executor/set_transaction_merchant.rb b/app/models/rule/action_executor/set_transaction_merchant.rb index 492ece52..f343a79f 100644 --- a/app/models/rule/action_executor/set_transaction_merchant.rb +++ b/app/models/rule/action_executor/set_transaction_merchant.rb @@ -17,14 +17,11 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor end scope.each do |txn| - Rule.transaction do - txn.log_enrichment!( - attribute_name: "merchant_id", - attribute_value: merchant.id, - source: "rule" - ) - txn.update!(merchant: merchant) - end + txn.enrich_attribute( + :merchant_id, + merchant.id, + source: "rule" + ) end end end diff --git a/app/models/rule/action_executor/set_transaction_name.rb b/app/models/rule/action_executor/set_transaction_name.rb index 39f3ee26..1dd89fa3 100644 --- a/app/models/rule/action_executor/set_transaction_name.rb +++ b/app/models/rule/action_executor/set_transaction_name.rb @@ -16,14 +16,11 @@ class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor end scope.each do |txn| - Rule.transaction do - txn.entry.log_enrichment!( - attribute_name: "name", - attribute_value: value, - source: "rule" - ) - txn.entry.update!(name: value) - end + txn.entry.enrich_attribute( + :name, + value, + source: "rule" + ) end end end diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb index 4d539496..d74029ca 100644 --- a/app/models/rule/action_executor/set_transaction_tags.rb +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -17,15 +17,11 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor end rows = scope.each do |txn| - Rule.transaction do - txn.log_enrichment!( - attribute_name: "tag_ids", - attribute_value: [ tag.id ], - source: "rule" - ) - - txn.update!(tag_ids: [ tag.id ]) - end + txn.enrich_attribute( + :tag_ids, + [ tag.id ], + source: "rule" + ) end end end diff --git a/test/models/concerns/enrichable_test.rb b/test/models/concerns/enrichable_test.rb new file mode 100644 index 00000000..890b79c1 --- /dev/null +++ b/test/models/concerns/enrichable_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class EnrichableTest < ActiveSupport::TestCase + setup do + @enrichable = accounts(:depository) + end + + test "can enrich multiple attributes" do + assert_difference "DataEnrichment.count", 2 do + @enrichable.enrich_attributes({ name: "Updated Checking", balance: 6_000 }, source: "plaid") + end + + assert_equal "Updated Checking", @enrichable.name + assert_equal 6_000, @enrichable.balance.to_d + end + + test "can enrich a single attribute" do + assert_difference "DataEnrichment.count", 1 do + @enrichable.enrich_attribute(:name, "Single Update", source: "ai") + end + + assert_equal "Single Update", @enrichable.name + end + + test "can lock an attribute" do + refute @enrichable.locked?(:name) + + @enrichable.lock_attr!(:name) + assert @enrichable.locked?(:name) + end + + test "can unlock an attribute" do + @enrichable.lock_attr!(:name) + assert @enrichable.locked?(:name) + + @enrichable.unlock_attr!(:name) + refute @enrichable.locked?(:name) + end + + test "can lock saved attributes" do + @enrichable.name = "User Override" + @enrichable.balance = 1_234 + @enrichable.save! + + @enrichable.lock_saved_attributes! + + assert @enrichable.locked?(:name) + assert @enrichable.locked?(:balance) + end + + test "does not enrich locked attributes" do + original_name = @enrichable.name + + @enrichable.lock_attr!(:name) + + assert_no_difference "DataEnrichment.count" do + @enrichable.enrich_attribute(:name, "Should Not Change", source: "plaid") + end + + assert_equal original_name, @enrichable.reload.name + end + + test "enrichable? reflects lock state" do + assert @enrichable.enrichable?(:name) + + @enrichable.lock_attr!(:name) + + refute @enrichable.enrichable?(:name) + end + + test "enrichable scope includes and excludes records based on lock state" do + # Initially, the record should be enrichable for :name + assert_includes Account.enrichable(:name), @enrichable + + @enrichable.lock_attr!(:name) + + refute_includes Account.enrichable(:name), @enrichable + end +end From 31485f538a47809636d770c88eb87214fcad1aed Mon Sep 17 00:00:00 2001 From: hatz Date: Tue, 20 May 2025 11:58:09 -0500 Subject: [PATCH 26/28] Update balance sheet --- app/views/pages/dashboard/_balance_sheet.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 994d8cfc..d79c566d 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -124,7 +124,7 @@
<% if idx < account_group.accounts.size - 1 %> - <%= render "shared/ruler", classes: "ml-12 mr-4" %> + <%= render "shared/ruler", classes: "ml-21 mr-4" %> <% end %> <% end %>
From caf35701efaac9c5f15c12c6239d705ae92f2fd5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 20 May 2025 14:00:31 -0400 Subject: [PATCH 27/28] Fix Docker builds after package updates --- .devcontainer/Dockerfile | 2 ++ Dockerfile | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cc56694e..9ae237ff 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ imagemagick \ iproute2 \ libpq-dev \ + libyaml-dev \ + libyaml-0-2 \ openssh-client \ postgresql-client \ vim diff --git a/Dockerfile b/Dockerfile index 81805bee..2092d0ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libvips postgresql-client + apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 # Set production environment ARG BUILD_COMMIT_SHA @@ -23,7 +23,7 @@ ENV RAILS_ENV="production" \ FROM base AS build # Install packages needed to build gems -RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config +RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev # Install application gems COPY .ruby-version Gemfile Gemfile.lock ./ From 868d4ede6ee2a0bd6a55eba07024d79144d2179c Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 20 May 2025 13:31:05 -0500 Subject: [PATCH 28/28] Sankey Diagram (#2269) * Enhance cash flow dashboard with new cash flow period handling and improved Sankey diagram rendering. Update D3 and related dependencies for better performance and features. * Fix Rubocop offenses * Refactor Sankey chart controller to use Number.parseFloat for value formatting and improve code readability by restructuring conditional logic for node shapes. --- app/controllers/pages_controller.rb | 111 ++++++++++ .../controllers/sankey_chart_controller.js | 204 ++++++++++++++++++ app/javascript/shims/d3-array-default.js | 3 + app/javascript/shims/d3-shape-default.js | 3 + app/views/pages/dashboard.html.erb | 16 +- .../pages/dashboard/_cashflow_sankey.html.erb | 24 +++ config/importmap.rb | 23 +- db/schema.rb | 2 +- vendor/javascript/@floating-ui--utils--dom.js | 4 +- vendor/javascript/d3-array.js | 2 + vendor/javascript/d3-axis.js | 2 + vendor/javascript/d3-brush.js | 2 + vendor/javascript/d3-chord.js | 2 + vendor/javascript/d3-color.js | 2 + vendor/javascript/d3-contour.js | 2 + vendor/javascript/d3-delaunay.js | 2 + vendor/javascript/d3-dispatch.js | 2 + vendor/javascript/d3-drag.js | 2 + vendor/javascript/d3-dsv.js | 2 + vendor/javascript/d3-ease.js | 2 + vendor/javascript/d3-fetch.js | 2 + vendor/javascript/d3-force.js | 2 + vendor/javascript/d3-format.js | 2 + vendor/javascript/d3-geo.js | 4 +- vendor/javascript/d3-hierarchy.js | 2 + vendor/javascript/d3-interpolate.js | 2 + vendor/javascript/d3-path.js | 4 +- vendor/javascript/d3-polygon.js | 2 + vendor/javascript/d3-quadtree.js | 2 + vendor/javascript/d3-random.js | 2 + vendor/javascript/d3-sankey.js | 4 + vendor/javascript/d3-scale-chromatic.js | 4 +- vendor/javascript/d3-scale.js | 2 + vendor/javascript/d3-selection.js | 2 + vendor/javascript/d3-shape.js | 4 +- vendor/javascript/d3-time-format.js | 2 + vendor/javascript/d3-time.js | 2 + vendor/javascript/d3-timer.js | 2 + vendor/javascript/d3-transition.js | 2 + vendor/javascript/d3-zoom.js | 2 + vendor/javascript/d3.js | 3 + vendor/javascript/delaunator.js | 2 + vendor/javascript/internmap.js | 2 + vendor/javascript/robust-predicates.js | 2 + 44 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 app/javascript/controllers/sankey_chart_controller.js create mode 100644 app/javascript/shims/d3-array-default.js create mode 100644 app/javascript/shims/d3-shape-default.js create mode 100644 app/views/pages/dashboard/_cashflow_sankey.html.erb create mode 100644 vendor/javascript/d3-sankey.js diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index c566d30e..1162483a 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -6,6 +6,23 @@ class PagesController < ApplicationController @balance_sheet = Current.family.balance_sheet @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 ] ] end @@ -31,4 +48,98 @@ class PagesController < ApplicationController def github_provider Provider::Registry.get_provider(:github) 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 --- + income_category_values = Hash.new(0.0) + income_totals.category_totals.each do |ct| + val = ct.total.to_f.round(2) + next if val.zero? || !ct.category.parent_id + income_category_values[ct.category.parent_id] += val + end + + income_totals.category_totals.each do |ct| + val = ct.total.to_f.round(2) + percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1) + next if val.zero? + + node_display_name = ct.category.name + node_value_for_label = val + income_category_values[ct.category.id] # This sum is for parent node display + node_percentage_for_label = total_income_val.zero? ? 0 : (node_value_for_label / total_income_val * 100).round(1) + + node_color = ct.category.color.presence || Category::COLORS.sample + current_cat_idx = add_node.call("income_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color) + + if ct.category.parent_id + parent_cat_idx = node_indices["income_#{ct.category.parent_id}"] + parent_cat_idx ||= add_node.call("income_#{ct.category.parent.id}", ct.category.parent.name, income_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::COLORS.sample) # Parent percentage will be recalc based on its total flow + links << { source: current_cat_idx, target: parent_cat_idx, value: val, color: node_color, percentage: percentage_of_total_income } + else + links << { source: current_cat_idx, target: cash_flow_idx, value: val, color: node_color, percentage: percentage_of_total_income } + end + end + + # --- Process Expense Side --- + expense_category_values = Hash.new(0.0) + expense_totals.category_totals.each do |ct| + val = ct.total.to_f.round(2) + next if val.zero? || !ct.category.parent_id + expense_category_values[ct.category.parent_id] += val + end + + expense_totals.category_totals.each do |ct| + val = ct.total.to_f.round(2) + percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1) + next if val.zero? + + node_display_name = ct.category.name + node_value_for_label = val + expense_category_values[ct.category.id] + node_percentage_for_label = total_expense_val.zero? ? 0 : (node_value_for_label / total_expense_val * 100).round(1) # Percentage relative to total expenses for expense nodes + + node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR + current_cat_idx = add_node.call("expense_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color) + + if ct.category.parent_id + parent_cat_idx = node_indices["expense_#{ct.category.parent_id}"] + parent_cat_idx ||= add_node.call("expense_#{ct.category.parent.id}", ct.category.parent.name, expense_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::UNCATEGORIZED_COLOR) + links << { source: parent_cat_idx, target: current_cat_idx, value: val, color: nodes[parent_cat_idx][:color], percentage: percentage_of_total_expense } + else + links << { source: cash_flow_idx, target: current_cat_idx, value: val, color: node_color, percentage: percentage_of_total_expense } + end + 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 diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js new file mode 100644 index 00000000..9601b088 --- /dev/null +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -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 })); + }); + } +} \ No newline at end of file diff --git a/app/javascript/shims/d3-array-default.js b/app/javascript/shims/d3-array-default.js new file mode 100644 index 00000000..1b1e088e --- /dev/null +++ b/app/javascript/shims/d3-array-default.js @@ -0,0 +1,3 @@ +import * as d3Array from "d3-array-src"; +export * from "d3-array-src"; +export default d3Array; \ No newline at end of file diff --git a/app/javascript/shims/d3-shape-default.js b/app/javascript/shims/d3-shape-default.js new file mode 100644 index 00000000..23920eda --- /dev/null +++ b/app/javascript/shims/d3-shape-default.js @@ -0,0 +1,3 @@ +import * as d3Shape from "d3-shape-src"; +export * from "d3-shape-src"; +export default d3Shape; \ No newline at end of file diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 1f2f347e..82de3bd1 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -31,13 +31,21 @@ period: @period } %>
+
+ <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> +
+ + <%= turbo_frame_tag "cashflow_sankey_section" do %> +
+ <%= render partial: "pages/dashboard/cashflow_sankey", locals: { + sankey_data: @cashflow_sankey_data, + period: @cashflow_period + } %> +
+ <% end %> <% else %>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
<% end %> - -
- <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> -
diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb new file mode 100644 index 00000000..4bddc3eb --- /dev/null +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -0,0 +1,24 @@ +<%# locals: (sankey_data:, period:) %> +
+
+

+ Cashflow +

+ + <%= 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 %> +
+ +
+
+
+
\ No newline at end of file diff --git a/config/importmap.rb b/config/importmap.rb index 7343cd06..5f1d8087 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -7,12 +7,12 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/components", under: "controllers", to: "" 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 # D3 packages -pin "d3" # @7.8.5 -pin "d3-array" # @3.2.4 +pin "d3" # @7.9.0 +pin "d3-array", to: "shims/d3-array-default.js" pin "d3-axis" # @3.0.0 pin "d3-brush" # @3.0.0 pin "d3-chord" # @3.0.1 @@ -26,7 +26,7 @@ pin "d3-ease" # @3.0.1 pin "d3-fetch" # @3.0.1 pin "d3-force" # @3.0.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-interpolate" # @3.0.1 pin "d3-path" # @3.1.0 @@ -34,9 +34,9 @@ pin "d3-polygon" # @3.0.1 pin "d3-quadtree" # @3.0.1 pin "d3-random" # @3.0.1 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-shape" # @3.2.0 +pin "d3-shape", to: "shims/d3-shape-default.js" pin "d3-time" # @3.1.0 pin "d3-time-format" # @4.1.0 pin "d3-timer" # @3.0.1 @@ -45,7 +45,10 @@ pin "d3-zoom" # @3.0.0 pin "delaunator" # @5.0.1 pin "internmap" # @2.0.3 pin "robust-predicates" # @3.0.2 -pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.6.9 -pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.6.6 -pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.6 -pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.6 +pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.7.0 +pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.7.0 +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.9 +pin "d3-sankey" # @0.12.3 +pin "d3-array-src", to: "d3-array.js" +pin "d3-shape-src", to: "d3-shape.js" diff --git a/db/schema.rb b/db/schema.rb index cdb863c4..fe602824 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -30,7 +30,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false diff --git a/vendor/javascript/@floating-ui--utils--dom.js b/vendor/javascript/@floating-ui--utils--dom.js index 098e5ac3..82db906e 100644 --- a/vendor/javascript/@floating-ui--utils--dom.js +++ b/vendor/javascript/@floating-ui--utils--dom.js @@ -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}; diff --git a/vendor/javascript/d3-array.js b/vendor/javascript/d3-array.js index 4b35f1e6..4b53af52 100644 --- a/vendor/javascript/d3-array.js +++ b/vendor/javascript/d3-array.js @@ -1,2 +1,4 @@ +// d3-array@3.2.4 downloaded from https://ga.jspm.io/npm:d3-array@3.2.4/src/index.js + import{InternMap as t,InternSet as n}from"internmap";export{InternMap,InternSet}from"internmap";function ascending(t,n){return null==t||null==n?NaN:tn?1:t>=n?0:NaN}function descending(t,n){return null==t||null==n?NaN:nt?1:n>=t?0:NaN}function bisector(t){let n,e,r;if(2!==t.length){n=ascending;e=(n,e)=>ascending(t(n),e);r=(n,e)=>t(n)-e}else{n=t===ascending||t===descending?t:zero;e=t;r=t}function left(t,r,o=0,i=t.length){if(o>>1;e(t[n],r)<0?o=n+1:i=n}while(o>>1;e(t[n],r)<=0?o=n+1:i=n}while(oe&&r(t[i-1],n)>-r(t[i],n)?i-1:i}return{left:left,center:center,right:right}}function zero(){return 0}function number(t){return null===t?NaN:+t}function*numbers(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}const e=bisector(ascending);const r=e.right;const o=e.left;const i=bisector(number).center;function blur(t,n){if(!((n=+n)>=0))throw new RangeError("invalid r");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError("invalid length");if(!e||!n)return t;const r=blurf(n);const o=t.slice();r(t,o,0,e,1);r(o,t,0,e,1);r(t,o,0,e,1);return t}const f=Blur2(blurf);const u=Blur2(blurfImage);function Blur2(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:o,width:i,height:f}=n;if(!((i=Math.floor(i))>=0))throw new RangeError("invalid width");if(!((f=Math.floor(void 0!==f?f:o.length/i))>=0))throw new RangeError("invalid height");if(!i||!f||!e&&!r)return n;const u=e&&t(e);const l=r&&t(r);const c=o.slice();if(u&&l){blurh(u,c,o,i,f);blurh(u,o,c,i,f);blurh(u,c,o,i,f);blurv(l,o,c,i,f);blurv(l,c,o,i,f);blurv(l,o,c,i,f)}else if(u){blurh(u,o,c,i,f);blurh(u,c,o,i,f);blurh(u,o,c,i,f)}else if(l){blurv(l,o,c,i,f);blurv(l,c,o,i,f);blurv(l,o,c,i,f)}return n}}function blurh(t,n,e,r,o){for(let i=0,f=r*o;i{r<<=2,o<<=2,i<<=2;n(t,e,r+0,o+0,i);n(t,e,r+1,o+1,i);n(t,e,r+2,o+2,i);n(t,e,r+3,o+3,i)}}function blurf(t){const n=Math.floor(t);if(n===t)return bluri(t);const e=t-n;const r=2*t+1;return(t,o,i,f,u)=>{if(!((f-=u)>=i))return;let l=n*o[i];const c=u*n;const s=c+u;for(let t=i,n=i+c;t{if(!((i-=f)>=o))return;let u=t*r[o];const l=f*t;for(let t=o,n=o+l;t=n&&++e;else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(o=+o)>=o&&++e}return e}function length$1(t){return 0|t.length}function empty(t){return!(t>0)}function arrayify(t){return"object"!==typeof t||"length"in t?t:Array.from(t)}function reducer(t){return n=>t(...n)}function cross(...t){const n="function"===typeof t[t.length-1]&&reducer(t.pop());t=t.map(arrayify);const e=t.map(length$1);const r=t.length-1;const o=new Array(r+1).fill(0);const i=[];if(r<0||e.some(empty))return i;while(true){i.push(o.map(((n,e)=>t[e][n])));let f=r;while(++o[f]===e[f]){if(0===f)return n?i.map(n):i;o[f--]=0}}}function cumsum(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:o=>e+=+n(o,r++,t)||0)}function variance(t,n){let e=0;let r;let o=0;let i=0;if(void 0===n){for(let n of t)if(null!=n&&(n=+n)>=n){r=n-o;o+=r/++e;i+=r*(n-o)}}else{let f=-1;for(let u of t)if(null!=(u=n(u,++f,t))&&(u=+u)>=u){r=u-o;o+=r/++e;i+=r*(u-o)}}if(e>1)return i/(e-1)}function deviation(t,n){const e=variance(t,n);return e?Math.sqrt(e):e}function extent(t,n){let e;let r;if(void 0===n){for(const n of t)if(null!=n)if(void 0===e)n>=n&&(e=r=n);else{e>n&&(e=n);r=i&&(e=r=i);else{e>i&&(e=i);r0){i=t[--o];while(o>0){n=i;e=t[--o];i=n+e;r=e-(i-n);if(r)break}if(o>0&&(r<0&&t[o-1]<0||r>0&&t[o-1]>0)){e=2*r;n=i+e;e==n-i&&(i=n)}}return i}}function fsum(t,n){const e=new Adder;if(void 0===n)for(let n of t)(n=+n)&&e.add(n);else{let r=-1;for(let o of t)(o=+n(o,++r,t))&&e.add(o)}return+e}function fcumsum(t,n){const e=new Adder;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):o=>e.add(+n(o,++r,t)||0))}function identity(t){return t}function group(t,...n){return nest(t,identity,identity,n)}function groups(t,...n){return nest(t,Array.from,identity,n)}function flatten$1(t,n){for(let e=1,r=n.length;et.pop().map((([n,e])=>[...t,n,e]))));return t}function flatGroup(t,...n){return flatten$1(groups(t,...n),n)}function flatRollup(t,n,...e){return flatten$1(rollups(t,n,...e),e)}function rollup(t,n,...e){return nest(t,identity,n,e)}function rollups(t,n,...e){return nest(t,Array.from,n,e)}function index(t,...n){return nest(t,identity,unique,n)}function indexes(t,...n){return nest(t,Array.from,unique,n)}function unique(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function nest(n,e,r,o){return function regroup(n,i){if(i>=o.length)return r(n);const f=new t;const u=o[i++];let l=-1;for(const t of n){const e=u(t,++l,n);const r=f.get(e);r?r.push(t):f.set(e,[t])}for(const[t,n]of f)f.set(t,regroup(n,i));return e(f)}(n,0)}function permute(t,n){return Array.from(n,(n=>t[n]))}function sort(t,...n){if("function"!==typeof t[Symbol.iterator])throw new TypeError("values is not iterable");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));if(n.length>1){n=n.map((n=>t.map(n)));r.sort(((t,e)=>{for(const r of n){const n=ascendingDefined(r[t],r[e]);if(n)return n}}))}else{e=t.map(e);r.sort(((t,n)=>ascendingDefined(e[t],e[n])))}return permute(t,r)}return t.sort(compareDefined(e))}function compareDefined(t=ascending){if(t===ascending)return ascendingDefined;if("function"!==typeof t)throw new TypeError("compare is not a function");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function ascendingDefined(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(tn?1:0)}function groupSort(t,n,e){return(2!==n.length?sort(rollup(t,n,e),(([t,n],[e,r])=>ascending(n,r)||ascending(t,e))):sort(group(t,e),(([t,e],[r,o])=>n(e,o)||ascending(t,r)))).map((([t])=>t))}var l=Array.prototype;var c=l.slice;l.map;function constant(t){return()=>t}const s=Math.sqrt(50),a=Math.sqrt(10),h=Math.sqrt(2);function tickSpec(t,n,e){const r=(n-t)/Math.max(0,e),o=Math.floor(Math.log10(r)),i=r/Math.pow(10,o),f=i>=s?10:i>=a?5:i>=h?2:1;let u,l,c;if(o<0){c=Math.pow(10,-o)/f;u=Math.round(t*c);l=Math.round(n*c);u/cn&&--l;c=-c}else{c=Math.pow(10,o)*f;u=Math.round(t/c);l=Math.round(n/c);u*cn&&--l}return l0))return[];if(t===n)return[t];const r=n=o))return[];const u=i-o+1,l=new Array(u);if(r)if(f<0)for(let t=0;t0){t=Math.floor(t/o)*o;n=Math.ceil(n/o)*o}else if(o<0){t=Math.ceil(t*o)/o;n=Math.floor(n*o)/o}r=o}}function thresholdSturges(t){return Math.max(1,Math.ceil(Math.log(count(t))/Math.LN2)+1)}function bin(){var t=identity,n=extent,e=thresholdSturges;function histogram(o){Array.isArray(o)||(o=Array.from(o));var i,f,u,l=o.length,c=new Array(l);for(i=0;i=h)if(t>=h&&n===extent){const t=tickIncrement(a,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}var m=d.length,p=0,g=m;while(d[p]<=a)++p;while(d[g-1]>h)--g;(p||g0?d[i-1]:a;y.x1=i0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(e=o)&&(e=o)}return e}function maxIndex(t,n){let e;let r=-1;let o=-1;if(void 0===n)for(const n of t){++o;null!=n&&(e=n)&&(e=n,r=o)}else for(let i of t)null!=(i=n(i,++o,t))&&(e=i)&&(e=i,r=o);return r}function min(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(e>o||void 0===e&&o>=o)&&(e=o)}return e}function minIndex(t,n){let e;let r=-1;let o=-1;if(void 0===n)for(const n of t){++o;null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=o)}else for(let i of t)null!=(i=n(i,++o,t))&&(e>i||void 0===e&&i>=i)&&(e=i,r=o);return r}function quickselect(t,n,e=0,r=Infinity,o){n=Math.floor(n);e=Math.floor(Math.max(0,e));r=Math.floor(Math.min(t.length-1,r));if(!(e<=n&&n<=r))return t;o=void 0===o?ascendingDefined:compareDefined(o);while(r>e){if(r-e>600){const i=r-e+1;const f=n-e+1;const u=Math.log(i);const l=.5*Math.exp(2*u/3);const c=.5*Math.sqrt(u*l*(i-l)/i)*(f-i/2<0?-1:1);const s=Math.max(e,Math.floor(n-f*l/i+c));const a=Math.min(r,Math.floor(n+(i-f)*l/i+c));quickselect(t,n,s,a,o)}const i=t[n];let f=e;let u=r;swap(t,e,n);o(t[r],i)>0&&swap(t,e,r);while(f0)--u}0===o(t[e],i)?swap(t,e,u):(++u,swap(t,u,r));u<=n&&(e=u+1);n<=u&&(r=u-1)}return t}function swap(t,n,e){const r=t[n];t[n]=t[e];t[e]=r}function greatest(t,n=ascending){let e;let r=false;if(1===n.length){let o;for(const i of t){const t=n(i);if(r?ascending(t,o)>0:0===ascending(t,t)){e=i;o=t;r=true}}}else for(const o of t)if(r?n(o,e)>0:0===n(o,o)){e=o;r=true}return e}function quantile(t,n,e){t=Float64Array.from(numbers(t,e));if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return min(t);if(n>=1)return max(t);var r,o=(r-1)*n,i=Math.floor(o),f=max(quickselect(t,i).subarray(0,i+1)),u=min(t.subarray(i+1));return f+(u-f)*(o-i)}}function quantileSorted(t,n,e=number){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,o=(r-1)*n,i=Math.floor(o),f=+e(t[i],i,t),u=+e(t[i+1],i+1,t);return f+(u-f)*(o-i)}}function quantileIndex(t,n,e=number){if(!isNaN(n=+n)){r=Float64Array.from(t,((n,r)=>number(e(t[r],r,t))));if(n<=0)return minIndex(r);if(n>=1)return maxIndex(r);var r,o=Uint32Array.from(t,((t,n)=>n)),i=r.length-1,f=Math.floor(i*n);quickselect(o,f,0,i,((t,n)=>ascendingDefined(r[t],r[n])));f=greatest(o.subarray(0,f+1),(t=>r[t]));return f>=0?f:-1}}function thresholdFreedmanDiaconis(t,n,e){const r=count(t),o=quantile(t,.75)-quantile(t,.25);return r&&o?Math.ceil((e-n)/(2*o*Math.pow(r,-1/3))):1}function thresholdScott(t,n,e){const r=count(t),o=deviation(t);return r&&o?Math.ceil((e-n)*Math.cbrt(r)/(3.49*o)):1}function mean(t,n){let e=0;let r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let o=-1;for(let i of t)null!=(i=n(i,++o,t))&&(i=+i)>=i&&(++e,r+=i)}if(e)return r/e}function median(t,n){return quantile(t,.5,n)}function medianIndex(t,n){return quantileIndex(t,.5,n)}function*flatten(t){for(const n of t)yield*n}function merge(t){return Array.from(flatten(t))}function mode(n,e){const r=new t;if(void 0===e)for(let t of n)null!=t&&t>=t&&r.set(t,(r.get(t)||0)+1);else{let t=-1;for(let o of n)null!=(o=e(o,++t,n))&&o>=o&&r.set(o,(r.get(o)||0)+1)}let o;let i=0;for(const[t,n]of r)if(n>i){i=n;o=t}return o}function pairs(t,n=pair){const e=[];let r;let o=false;for(const i of t){o&&e.push(n(r,i));r=i;o=true}return e}function pair(t,n){return[t,n]}function range(t,n,e){t=+t,n=+n,e=(o=arguments.length)<2?(n=t,t=0,1):o<3?1:+e;var r=-1,o=0|Math.max(0,Math.ceil((n-t)/e)),i=new Array(o);while(++rn(e[t],e[r]);let o,i;t=Uint32Array.from(e,((t,n)=>n));t.sort(n===ascending?(t,n)=>ascendingDefined(e[t],e[n]):compareDefined(compareIndex));t.forEach(((t,n)=>{const e=compareIndex(t,void 0===o?t:o);if(e>=0){(void 0===o||e>0)&&(o=t,i=n);r[t]=i}else r[t]=NaN}));return r}function least(t,n=ascending){let e;let r=false;if(1===n.length){let o;for(const i of t){const t=n(i);if(r?ascending(t,o)<0:0===ascending(t,t)){e=i;o=t;r=true}}}else for(const o of t)if(r?n(o,e)<0:0===n(o,o)){e=o;r=true}return e}function leastIndex(t,n=ascending){if(1===n.length)return minIndex(t,n);let e;let r=-1;let o=-1;for(const i of t){++o;if(r<0?0===n(i,i):n(i,e)<0){e=i;r=o}}return r}function greatestIndex(t,n=ascending){if(1===n.length)return maxIndex(t,n);let e;let r=-1;let o=-1;for(const i of t){++o;if(r<0?0===n(i,i):n(i,e)>0){e=i;r=o}}return r}function scan(t,n){const e=leastIndex(t,n);return e<0?void 0:e}var d=shuffler(Math.random);function shuffler(t){return function shuffle(n,e=0,r=n.length){let o=r-(e=+e);while(o){const r=t()*o--|0,i=n[o+e];n[o+e]=n[r+e];n[r+e]=i}return n}}function sum(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let o of t)(o=+n(o,++r,t))&&(e+=o)}return e}function transpose(t){if(!(o=t.length))return[];for(var n=-1,e=min(t,length),r=new Array(e);++nn(e,r,t)))}function reduce(t,n,e){if("function"!==typeof n)throw new TypeError("reducer is not a function");const r=t[Symbol.iterator]();let o,i,f=-1;if(arguments.length<3){({done:o,value:e}=r.next());if(o)return;++f}while(({done:o,value:i}=r.next()),!o)e=n(e,i,++f,t);return e}function reverse(t){if("function"!==typeof t[Symbol.iterator])throw new TypeError("values is not iterable");return Array.from(t).reverse()}function difference(t,...e){t=new n(t);for(const n of e)for(const e of n)t.delete(e);return t}function disjoint(t,e){const r=e[Symbol.iterator](),o=new n;for(const n of t){if(o.has(n))return false;let t,e;while(({value:t,done:e}=r.next())){if(e)break;if(Object.is(n,t))return false;o.add(t)}}return true}function intersection(t,...e){t=new n(t);e=e.map(set);t:for(const n of t)for(const r of e)if(!r.has(n)){t.delete(n);continue t}return t}function set(t){return t instanceof n?t:new n(t)}function superset(t,n){const e=t[Symbol.iterator](),r=new Set;for(const t of n){const n=intern(t);if(r.has(n))continue;let o,i;while(({value:o,done:i}=e.next())){if(i)return false;const t=intern(o);r.add(t);if(Object.is(n,t))break}}return true}function intern(t){return null!==t&&"object"===typeof t?t.valueOf():t}function subset(t,n){return superset(n,t)}function union(...t){const e=new n;for(const n of t)for(const t of n)e.add(t);return e}export{Adder,ascending,bin,r as bisect,i as bisectCenter,o as bisectLeft,r as bisectRight,bisector,blur,f as blur2,u as blurImage,count,cross,cumsum,descending,deviation,difference,disjoint,every,extent,fcumsum,filter,flatGroup,flatRollup,fsum,greatest,greatestIndex,group,groupSort,groups,bin as histogram,index,indexes,intersection,least,leastIndex,map,max,maxIndex,mean,median,medianIndex,merge,min,minIndex,mode,nice,pairs,permute,quantile,quantileIndex,quantileSorted,quickselect,range,rank,reduce,reverse,rollup,rollups,scan,d as shuffle,shuffler,some,sort,subset,sum,superset,thresholdFreedmanDiaconis,thresholdScott,thresholdSturges,tickIncrement,tickStep,ticks,transpose,union,variance,zip}; diff --git a/vendor/javascript/d3-axis.js b/vendor/javascript/d3-axis.js index b8621a79..b541237b 100644 --- a/vendor/javascript/d3-axis.js +++ b/vendor/javascript/d3-axis.js @@ -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}; diff --git a/vendor/javascript/d3-brush.js b/vendor/javascript/d3-brush.js index 53cbc53a..d1270ace 100644 --- a/vendor/javascript/d3-brush.js +++ b/vendor/javascript/d3-brush.js @@ -1,2 +1,4 @@ +// d3-brush@3.0.0 downloaded from https://ga.jspm.io/npm:d3-brush@3.0.0/src/index.js + import{dispatch as e}from"d3-dispatch";import{dragDisable as t,dragEnable as n}from"d3-drag";import{interpolate as r}from"d3-interpolate";import{select as u,pointer as i}from"d3-selection";import{interrupt as s}from"d3-transition";var constant=e=>()=>e;function BrushEvent(e,{sourceEvent:t,target:n,selection:r,mode:u,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:true,configurable:true},sourceEvent:{value:t,enumerable:true,configurable:true},target:{value:n,enumerable:true,configurable:true},selection:{value:r,enumerable:true,configurable:true},mode:{value:u,enumerable:true,configurable:true},_:{value:i}})}function nopropagation(e){e.stopImmediatePropagation()}function noevent(e){e.preventDefault();e.stopImmediatePropagation()}var o={name:"drag"},a={name:"space"},l={name:"handle"},c={name:"center"};const{abs:h,max:f,min:d}=Math;function number1(e){return[+e[0],+e[1]]}function number2(e){return[number1(e[0]),number1(e[1])]}var b={name:"x",handles:["w","e"].map(type),input:function(e,t){return null==e?null:[[+e[0],t[0][1]],[+e[1],t[1][1]]]},output:function(e){return e&&[e[0][0],e[1][0]]}};var p={name:"y",handles:["n","s"].map(type),input:function(e,t){return null==e?null:[[t[0][0],+e[0]],[t[1][0],+e[1]]]},output:function(e){return e&&[e[0][1],e[1][1]]}};var m={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(type),input:function(e){return null==e?null:number2(e)},output:function(e){return e}};var v={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"};var y={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"};var w={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"};var g={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1};var _={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function type(e){return{type:e}}function defaultFilter(e){return!e.ctrlKey&&!e.button}function defaultExtent(){var e=this.ownerSVGElement||this;if(e.hasAttribute("viewBox")){e=e.viewBox.baseVal;return[[e.x,e.y],[e.x+e.width,e.y+e.height]]}return[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]}function defaultTouchable(){return navigator.maxTouchPoints||"ontouchstart"in this}function local(e){while(!e.__brush)if(!(e=e.parentNode))return;return e.__brush}function empty(e){return e[0][0]===e[1][0]||e[0][1]===e[1][1]}function brushSelection(e){var t=e.__brush;return t?t.dim.output(t.selection):null}function brushX(){return brush$1(b)}function brushY(){return brush$1(p)}function brush(){return brush$1(m)}function brush$1(m){var k,x=defaultExtent,E=defaultFilter,z=defaultTouchable,A=true,T=e("start","brush","end"),K=6;function brush(e){var t=e.property("__brush",initialize).selectAll(".overlay").data([type("overlay")]);t.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",v.overlay).merge(t).each((function(){var e=local(this).extent;u(this).attr("x",e[0][0]).attr("y",e[0][1]).attr("width",e[1][0]-e[0][0]).attr("height",e[1][1]-e[0][1])}));e.selectAll(".selection").data([type("selection")]).enter().append("rect").attr("class","selection").attr("cursor",v.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var n=e.selectAll(".handle").data(m.handles,(function(e){return e.type}));n.exit().remove();n.enter().append("rect").attr("class",(function(e){return"handle handle--"+e.type})).attr("cursor",(function(e){return v[e.type]}));e.each(redraw).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",started).filter(z).on("touchstart.brush",started).on("touchmove.brush",touchmoved).on("touchend.brush touchcancel.brush",touchended).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}brush.move=function(e,t,n){e.tween?e.on("start.brush",(function(e){emitter(this,arguments).beforestart().start(e)})).on("interrupt.brush end.brush",(function(e){emitter(this,arguments).end(e)})).tween("brush",(function(){var e=this,n=e.__brush,u=emitter(e,arguments),i=n.selection,s=m.input("function"===typeof t?t.apply(this,arguments):t,n.extent),o=r(i,s);function tween(t){n.selection=1===t&&null===s?null:o(t);redraw.call(e);u.brush()}return null!==i&&null!==s?tween:tween(1)})):e.each((function(){var e=this,r=arguments,u=e.__brush,i=m.input("function"===typeof t?t.apply(e,r):t,u.extent),o=emitter(e,r).beforestart();s(e);u.selection=null===i?null:i;redraw.call(e);o.start(n).brush(n).end(n)}))};brush.clear=function(e,t){brush.move(e,null,t)};function redraw(){var e=u(this),t=local(this).selection;if(t){e.selectAll(".selection").style("display",null).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1]);e.selectAll(".handle").style("display",null).attr("x",(function(e){return"e"===e.type[e.type.length-1]?t[1][0]-K/2:t[0][0]-K/2})).attr("y",(function(e){return"s"===e.type[0]?t[1][1]-K/2:t[0][1]-K/2})).attr("width",(function(e){return"n"===e.type||"s"===e.type?t[1][0]-t[0][0]+K:K})).attr("height",(function(e){return"e"===e.type||"w"===e.type?t[1][1]-t[0][1]+K:K}))}else e.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function emitter(e,t,n){var r=e.__brush.emitter;return!r||n&&r.clean?new Emitter(e,t,n):r}function Emitter(e,t,n){this.that=e;this.args=t;this.state=e.__brush;this.active=0;this.clean=n}Emitter.prototype={beforestart:function(){1===++this.active&&(this.state.emitter=this,this.starting=true);return this},start:function(e,t){this.starting?(this.starting=false,this.emit("start",e,t)):this.emit("brush",e);return this},brush:function(e,t){this.emit("brush",e,t);return this},end:function(e,t){0===--this.active&&(delete this.state.emitter,this.emit("end",e,t));return this},emit:function(e,t,n){var r=u(this.that).datum();T.call(e,this.that,new BrushEvent(e,{sourceEvent:t,target:brush,selection:m.output(this.state.selection),mode:n,dispatch:T}),r)}};function started(e){if((!k||e.touches)&&E.apply(this,arguments)){var r,x,z,T,K,B,P,S,V,$,C,F=this,I=e.target.__data__.type,M="selection"===(A&&e.metaKey?I="overlay":I)?o:A&&e.altKey?c:l,X=m===p?null:g[I],Y=m===b?null:_[I],j=local(F),D=j.extent,G=j.selection,N=D[0][0],O=D[0][1],q=D[1][0],H=D[1][1],J=0,L=0,Q=X&&Y&&A&&e.shiftKey,R=Array.from(e.touches||[e],(e=>{const t=e.identifier;e=i(e,F);e.point0=e.slice();e.identifier=t;return e}));s(F);var U=emitter(F,arguments,true).beforestart();if("overlay"===I){G&&(V=true);const t=[R[0],R[1]||R[0]];j.selection=G=[[r=m===p?N:d(t[0][0],t[1][0]),z=m===b?O:d(t[0][1],t[1][1])],[K=m===p?q:f(t[0][0],t[1][0]),P=m===b?H:f(t[0][1],t[1][1])]];R.length>1&&move(e)}else{r=G[0][0];z=G[0][1];K=G[1][0];P=G[1][1]}x=r;T=z;B=K;S=P;var W=u(F).attr("pointer-events","none");var Z=W.selectAll(".overlay").attr("cursor",v[I]);if(e.touches){U.moved=moved;U.ended=ended}else{var ee=u(e.view).on("mousemove.brush",moved,true).on("mouseup.brush",ended,true);A&&ee.on("keydown.brush",keydowned,true).on("keyup.brush",keyupped,true);t(e.view)}redraw.call(F);U.start(e,M.name)}function moved(e){for(const t of e.changedTouches||[e])for(const e of R)e.identifier===t.identifier&&(e.cur=i(t,F));if(Q&&!$&&!C&&1===R.length){const e=R[0];h(e.cur[0]-e[0])>h(e.cur[1]-e[1])?C=true:$=true}for(const e of R)e.cur&&(e[0]=e.cur[0],e[1]=e.cur[1]);V=true;noevent(e);move(e)}function move(e){const t=R[0],n=t.point0;var u;J=t[0]-n[0];L=t[1]-n[1];switch(M){case a:case o:X&&(J=f(N-r,d(q-K,J)),x=r+J,B=K+J);Y&&(L=f(O-z,d(H-P,L)),T=z+L,S=P+L);break;case l:if(R[1]){X&&(x=f(N,d(q,R[0][0])),B=f(N,d(q,R[1][0])),X=1);Y&&(T=f(O,d(H,R[0][1])),S=f(O,d(H,R[1][1])),Y=1)}else{X<0?(J=f(N-r,d(q-r,J)),x=r+J,B=K):X>0&&(J=f(N-K,d(q-K,J)),x=r,B=K+J);Y<0?(L=f(O-z,d(H-z,L)),T=z+L,S=P):Y>0&&(L=f(O-P,d(H-P,L)),T=z,S=P+L)}break;case c:X&&(x=f(N,d(q,r-J*X)),B=f(N,d(q,K+J*X)));Y&&(T=f(O,d(H,z-L*Y)),S=f(O,d(H,P+L*Y)));break}if(B0&&(r=x-J);Y<0?P=S-L:Y>0&&(z=T-L);M=a;Z.attr("cursor",v.selection);move(e)}break;default:return}noevent(e)}function keyupped(e){switch(e.keyCode){case 16:if(Q){$=C=Q=false;move(e)}break;case 18:if(M===c){X<0?K=B:X>0&&(r=x);Y<0?P=S:Y>0&&(z=T);M=l;move(e)}break;case 32:if(M===a){if(e.altKey){X&&(K=B-J*X,r=x+J*X);Y&&(P=S-L*Y,z=T+L*Y);M=c}else{X<0?K=B:X>0&&(r=x);Y<0?P=S:Y>0&&(z=T);M=l}Z.attr("cursor",v[I]);move(e)}break;default:return}noevent(e)}}function touchmoved(e){emitter(this,arguments).moved(e)}function touchended(e){emitter(this,arguments).ended(e)}function initialize(){var e=this.__brush||{selection:null};e.extent=number2(x.apply(this,arguments));e.dim=m;return e}brush.extent=function(e){return arguments.length?(x="function"===typeof e?e:constant(number2(e)),brush):x};brush.filter=function(e){return arguments.length?(E="function"===typeof e?e:constant(!!e),brush):E};brush.touchable=function(e){return arguments.length?(z="function"===typeof e?e:constant(!!e),brush):z};brush.handleSize=function(e){return arguments.length?(K=+e,brush):K};brush.keyModifiers=function(e){return arguments.length?(A=!!e,brush):A};brush.on=function(){var e=T.on.apply(T,arguments);return e===T?brush:e};return brush}export{brush,brushSelection,brushX,brushY}; diff --git a/vendor/javascript/d3-chord.js b/vendor/javascript/d3-chord.js index ae586d74..8b0c622c 100644 --- a/vendor/javascript/d3-chord.js +++ b/vendor/javascript/d3-chord.js @@ -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;re(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(ti){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}; diff --git a/vendor/javascript/d3-color.js b/vendor/javascript/d3-color.js index 16068986..a8f795fe 100644 --- a/vendor/javascript/d3-color.js +++ b/vendor/javascript/d3-color.js @@ -1,2 +1,4 @@ +// d3-color@3.1.0 downloaded from https://ga.jspm.io/npm:d3-color@3.1.0/src/index.js + function define(t,e,r){t.prototype=e.prototype=r;r.constructor=t}function extend(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}function Color(){}var t=.7;var e=1/t;var r="\\s*([+-]?\\d+)\\s*",n="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",i="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",a=/^#([0-9a-f]{3,8})$/,l=new RegExp(`^rgb\\(${r},${r},${r}\\)$`),o=new RegExp(`^rgb\\(${i},${i},${i}\\)$`),h=new RegExp(`^rgba\\(${r},${r},${r},${n}\\)$`),s=new RegExp(`^rgba\\(${i},${i},${i},${n}\\)$`),c=new RegExp(`^hsl\\(${n},${i},${i}\\)$`),b=new RegExp(`^hsla\\(${n},${i},${i},${n}\\)$`);var u={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};define(Color,color,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:color_formatHex,formatHex:color_formatHex,formatHex8:color_formatHex8,formatHsl:color_formatHsl,formatRgb:color_formatRgb,toString:color_formatRgb});function color_formatHex(){return this.rgb().formatHex()}function color_formatHex8(){return this.rgb().formatHex8()}function color_formatHsl(){return hslConvert(this).formatHsl()}function color_formatRgb(){return this.rgb().formatRgb()}function color(t){var e,r;t=(t+"").trim().toLowerCase();return(e=a.exec(t))?(r=e[1].length,e=parseInt(e[1],16),6===r?rgbn(e):3===r?new Rgb(e>>8&15|e>>4&240,e>>4&15|240&e,(15&e)<<4|15&e,1):8===r?rgba(e>>24&255,e>>16&255,e>>8&255,(255&e)/255):4===r?rgba(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|240&e,((15&e)<<4|15&e)/255):null):(e=l.exec(t))?new Rgb(e[1],e[2],e[3],1):(e=o.exec(t))?new Rgb(255*e[1]/100,255*e[2]/100,255*e[3]/100,1):(e=h.exec(t))?rgba(e[1],e[2],e[3],e[4]):(e=s.exec(t))?rgba(255*e[1]/100,255*e[2]/100,255*e[3]/100,e[4]):(e=c.exec(t))?hsla(e[1],e[2]/100,e[3]/100,1):(e=b.exec(t))?hsla(e[1],e[2]/100,e[3]/100,e[4]):u.hasOwnProperty(t)?rgbn(u[t]):"transparent"===t?new Rgb(NaN,NaN,NaN,0):null}function rgbn(t){return new Rgb(t>>16&255,t>>8&255,255&t,1)}function rgba(t,e,r,n){n<=0&&(t=e=r=NaN);return new Rgb(t,e,r,n)}function rgbConvert(t){t instanceof Color||(t=color(t));if(!t)return new Rgb;t=t.rgb();return new Rgb(t.r,t.g,t.b,t.opacity)}function rgb(t,e,r,n){return 1===arguments.length?rgbConvert(t):new Rgb(t,e,r,null==n?1:n)}function Rgb(t,e,r,n){this.r=+t;this.g=+e;this.b=+r;this.opacity=+n}define(Rgb,rgb,extend(Color,{brighter(t){t=null==t?e:Math.pow(e,t);return new Rgb(this.r*t,this.g*t,this.b*t,this.opacity)},darker(e){e=null==e?t:Math.pow(t,e);return new Rgb(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new Rgb(clampi(this.r),clampi(this.g),clampi(this.b),clampa(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:rgb_formatHex,formatHex:rgb_formatHex,formatHex8:rgb_formatHex8,formatRgb:rgb_formatRgb,toString:rgb_formatRgb}));function rgb_formatHex(){return`#${hex(this.r)}${hex(this.g)}${hex(this.b)}`}function rgb_formatHex8(){return`#${hex(this.r)}${hex(this.g)}${hex(this.b)}${hex(255*(isNaN(this.opacity)?1:this.opacity))}`}function rgb_formatRgb(){const t=clampa(this.opacity);return`${1===t?"rgb(":"rgba("}${clampi(this.r)}, ${clampi(this.g)}, ${clampi(this.b)}${1===t?")":`, ${t})`}`}function clampa(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function clampi(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function hex(t){t=clampi(t);return(t<16?"0":"")+t.toString(16)}function hsla(t,e,r,n){n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN);return new Hsl(t,e,r,n)}function hslConvert(t){if(t instanceof Hsl)return new Hsl(t.h,t.s,t.l,t.opacity);t instanceof Color||(t=color(t));if(!t)return new Hsl;if(t instanceof Hsl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),l=NaN,o=a-i,h=(a+i)/2;if(o){l=e===a?(r-n)/o+6*(r0&&h<1?0:l;return new Hsl(l,o,h,t.opacity)}function hsl(t,e,r,n){return 1===arguments.length?hslConvert(t):new Hsl(t,e,r,null==n?1:n)}function Hsl(t,e,r,n){this.h=+t;this.s=+e;this.l=+r;this.opacity=+n}define(Hsl,hsl,extend(Color,{brighter(t){t=null==t?e:Math.pow(e,t);return new Hsl(this.h,this.s,this.l*t,this.opacity)},darker(e){e=null==e?t:Math.pow(t,e);return new Hsl(this.h,this.s,this.l*e,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new Rgb(hsl2rgb(t>=240?t-240:t+120,i,n),hsl2rgb(t,i,n),hsl2rgb(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new Hsl(clamph(this.h),clampt(this.s),clampt(this.l),clampa(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=clampa(this.opacity);return`${1===t?"hsl(":"hsla("}${clamph(this.h)}, ${100*clampt(this.s)}%, ${100*clampt(this.l)}%${1===t?")":`, ${t})`}`}}));function clamph(t){t=(t||0)%360;return t<0?t+360:t}function clampt(t){return Math.max(0,Math.min(1,t||0))}function hsl2rgb(t,e,r){return 255*(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)}const g=Math.PI/180;const p=180/Math.PI;const f=18,m=.96422,d=1,y=.82521,w=4/29,x=6/29,$=3*x*x,v=x*x*x;function labConvert(t){if(t instanceof Lab)return new Lab(t.l,t.a,t.b,t.opacity);if(t instanceof Hcl)return hcl2lab(t);t instanceof Rgb||(t=rgbConvert(t));var e,r,n=rgb2lrgb(t.r),i=rgb2lrgb(t.g),a=rgb2lrgb(t.b),l=xyz2lab((.2225045*n+.7168786*i+.0606169*a)/d);if(n===i&&i===a)e=r=l;else{e=xyz2lab((.4360747*n+.3850649*i+.1430804*a)/m);r=xyz2lab((.0139322*n+.0971045*i+.7141733*a)/y)}return new Lab(116*l-16,500*(e-l),200*(l-r),t.opacity)}function gray(t,e){return new Lab(t,0,0,null==e?1:e)}function lab(t,e,r,n){return 1===arguments.length?labConvert(t):new Lab(t,e,r,null==n?1:n)}function Lab(t,e,r,n){this.l=+t;this.a=+e;this.b=+r;this.opacity=+n}define(Lab,lab,extend(Color,{brighter(t){return new Lab(this.l+f*(null==t?1:t),this.a,this.b,this.opacity)},darker(t){return new Lab(this.l-f*(null==t?1:t),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;e=m*lab2xyz(e);t=d*lab2xyz(t);r=y*lab2xyz(r);return new Rgb(lrgb2rgb(3.1338561*e-1.6168667*t-.4906146*r),lrgb2rgb(-.9787684*e+1.9161415*t+.033454*r),lrgb2rgb(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));function xyz2lab(t){return t>v?Math.pow(t,1/3):t/$+w}function lab2xyz(t){return t>x?t*t*t:$*(t-w)}function lrgb2rgb(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function rgb2lrgb(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hclConvert(t){if(t instanceof Hcl)return new Hcl(t.h,t.c,t.l,t.opacity);t instanceof Lab||(t=labConvert(t));if(0===t.a&&0===t.b)return new Hcl(NaN,0()=>n;function contains(n,t){var r,o=-1,i=t.length;while(++oo!==d>o&&r<(l-f)*(o-c)/(d-c)+f&&(i=-i)}return i}function segmentContains(n,t,r){var o;return collinear(n,t,r)&&within(n[o=+(n[0]===t[0])],r[o],t[o])}function collinear(n,t,r){return(t[0]-n[0])*(r[1]-n[1])===(r[0]-n[0])*(t[1]-n[1])}function within(n,t,r){return n<=t&&t<=r||r<=t&&t<=n}function noop(){}var u=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function Contours(){var i=1,e=1,a=n,f=smoothLinear;function contours(n){var i=a(n);if(Array.isArray(i))i=i.slice().sort(ascending);else{const e=t(n,finite);i=r(...o(e[0],e[1],i),i);while(i[i.length-1]>=e[1])i.pop();while(i[1]contour(n,t)))}function contour(n,t){const r=null==t?NaN:+t;if(isNaN(r))throw new Error(`invalid value: ${t}`);var o=[],i=[];isorings(n,r,(function(t){f(t,n,r);area(t)>0?o.push([t]):i.push(t)}));i.forEach((function(n){for(var t,r=0,i=o.length;r=t;u[c<<2].forEach(stitch);while(++o0&&o0&&a=0&&r>=0))throw new Error("invalid size");return i=t,e=r,contours};contours.thresholds=function(n){return arguments.length?(a="function"===typeof n?n:Array.isArray(n)?constant(s.call(n)):constant(n),contours):a};contours.smooth=function(n){return arguments.length?(f=n?smoothLinear:noop,contours):f===smoothLinear};return contours}function finite(n){return isFinite(n)?n:NaN}function above(n,t){return null!=n&&+n>=t}function valid(n){return null==n||isNaN(n=+n)?-Infinity:n}function smooth1(n,t,r,o){const i=o-t;const e=r-t;const a=isFinite(i)||isFinite(e)?i/e:Math.sign(i)/Math.sign(e);return isNaN(a)?n:n+a-.5}function defaultX(n){return n[0]}function defaultY(n){return n[1]}function defaultWeight(){return 1}function density(){var n=defaultX,t=defaultY,o=defaultWeight,a=960,u=500,f=20,c=2,h=3*f,l=a+2*h>>c,d=u+2*h>>c,g=constant(20);function grid(r){var e=new Float32Array(l*d),a=Math.pow(2,-c),s=-1;for(const i of r){var u=(n(i,++s,r)+h)*a,g=(t(i,s,r)+h)*a,v=+o(i,s,r);if(v&&u>=0&&u=0&&gn*i)))(t).map(((n,t)=>(n.value=+o[t],transform(n))))}density.contours=function(n){var t=grid(n),r=Contours().size([l,d]),o=Math.pow(2,2*c),contour=n=>{n=+n;var i=transform(r.contour(t,n*o));i.value=n;return i};Object.defineProperty(contour,"max",{get:()=>e(t)/o});return contour};function transform(n){n.coordinates.forEach(transformPolygon);return n}function transformPolygon(n){n.forEach(transformRing)}function transformRing(n){n.forEach(transformPoint)}function transformPoint(n){n[0]=n[0]*Math.pow(2,c)-h;n[1]=n[1]*Math.pow(2,c)-h}function resize(){h=3*f;l=a+2*h>>c;d=u+2*h>>c;return density}density.x=function(t){return arguments.length?(n="function"===typeof t?t:constant(+t),density):n};density.y=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),density):t};density.weight=function(n){return arguments.length?(o="function"===typeof n?n:constant(+n),density):o};density.size=function(n){if(!arguments.length)return[a,u];var t=+n[0],r=+n[1];if(!(t>=0&&r>=0))throw new Error("invalid size");return a=t,u=r,resize()};density.cellSize=function(n){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return c=Math.floor(Math.log(n)/Math.LN2),resize()};density.thresholds=function(n){return arguments.length?(g="function"===typeof n?n:Array.isArray(n)?constant(s.call(n)):constant(n),density):g};density.bandwidth=function(n){if(!arguments.length)return Math.sqrt(f*(f+1));if(!((n=+n)>=0))throw new Error("invalid bandwidth");return f=(Math.sqrt(4*n*n+1)-1)/2,resize()};return density}export{density as contourDensity,Contours as contours}; diff --git a/vendor/javascript/d3-delaunay.js b/vendor/javascript/d3-delaunay.js index a9568a9e..7d9c7243 100644 --- a/vendor/javascript/d3-delaunay.js +++ b/vendor/javascript/d3-delaunay.js @@ -1,2 +1,4 @@ +// d3-delaunay@6.0.4 downloaded from https://ga.jspm.io/npm:d3-delaunay@6.0.4/src/index.js + import t from"delaunator";const n=1e-6;class Path{constructor(){this._x0=this._y0=this._x1=this._y1=null;this._=""}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){if(null!==this._x1){this._x1=this._x0,this._y1=this._y0;this._+="Z"}}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,e,i){t=+t,e=+e,i=+i;const s=t+i;const l=e;if(i<0)throw new Error("negative radius");null===this._x1?this._+=`M${s},${l}`:(Math.abs(this._x1-s)>n||Math.abs(this._y1-l)>n)&&(this._+="L"+s+","+l);i&&(this._+=`A${i},${i},0,1,1,${t-i},${e}A${i},${i},0,1,1,${this._x1=s},${this._y1=l}`)}rect(t,n,e,i){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+i}h${-e}Z`}value(){return this._||null}}class Polygon{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class Voronoi{constructor(t,[n,e,i,s]=[0,0,960,500]){if(!((i=+i)>=(n=+n))||!((s=+s)>=(e=+e)))throw new Error("invalid bounds");this.delaunay=t;this._circumcenters=new Float64Array(2*t.points.length);this.vectors=new Float64Array(2*t.points.length);this.xmax=i,this.xmin=n;this.ymax=s,this.ymin=e;this._init()}update(){this.delaunay.update();this._init();return this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:i}=this;let s,l;const h=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let i,o,r=0,c=0,a=e.length;r1)s-=2;for(let t=2;t0){if(n>=this.ymax)return null;(s=(this.ymax-n)/i)0){if(t>=this.xmax)return null;(s=(this.xmax-t)/e)this.xmax?2:0)|(nthis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n1e-10)return false}return true}function jitter(t,n,e){return[t+Math.sin(t+n)*e,n+Math.cos(t-n)*e]}class Delaunay{static from(t,n=pointX,e=pointY,i){return new Delaunay("length"in t?flatArray(t,n,e,i):Float64Array.from(flatIterable(t,n,e,i)))}constructor(n){this._delaunator=new t(n);this.inedges=new Int32Array(n.length/2);this._hullIndex=new Int32Array(n.length/2);this.points=this._delaunator.coords;this._init()}update(){this._delaunator.update();this._init();return this}_init(){const n=this._delaunator,e=this.points;if(n.hull&&n.hull.length>2&&collinear(n)){this.collinear=Int32Array.from({length:e.length/2},((t,n)=>n)).sort(((t,n)=>e[2*t]-e[2*n]||e[2*t+1]-e[2*n+1]));const n=this.collinear[0],i=this.collinear[this.collinear.length-1],s=[e[2*n],e[2*n+1],e[2*i],e[2*i+1]],l=1e-8*Math.hypot(s[3]-s[1],s[2]-s[0]);for(let t=0,n=e.length/2;t0){this.triangles=new Int32Array(3).fill(-1);this.halfedges=new Int32Array(3).fill(-1);this.triangles[0]=s[0];h[s[0]]=1;if(2===s.length){h[s[1]]=0;this.triangles[1]=s[1];this.triangles[2]=s[1]}}}voronoi(t){return new Voronoi(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:i,halfedges:s,triangles:l,collinear:h}=this;if(h){const n=h.indexOf(t);n>0&&(yield h[n-1]);n=0&&s!==e&&s!==i)e=s;return s}_step(t,n,e){const{inedges:s,hull:l,_hullIndex:h,halfedges:o,triangles:r,points:c}=this;if(-1===s[t]||!c.length)return(t+1)%(c.length>>1);let a=t;let u=i(n-c[2*t],2)+i(e-c[2*t+1],2);const g=s[t];let f=g;do{let s=r[f];const g=i(n-c[2*s],2)+i(e-c[2*s+1],2);g{}};function dispatch(){for(var n,t=0,e=arguments.length,r={};t=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(++a0)for(var e,r,i=new Array(e),a=0;a()=>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;n9999?"+"+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)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+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)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}; diff --git a/vendor/javascript/d3-force.js b/vendor/javascript/d3-force.js index ece9edd8..e1a17d2d 100644 --- a/vendor/javascript/d3-force.js +++ b/vendor/javascript/d3-force.js @@ -1,2 +1,4 @@ +// d3-force@3.0.0 downloaded from https://ga.jspm.io/npm:d3-force@3.0.0/src/index.js + import{quadtree as n}from"d3-quadtree";import{dispatch as t}from"d3-dispatch";import{timer as e}from"d3-timer";function center(n,t){var e,i=1;null==n&&(n=0);null==t&&(t=0);function force(){var r,o,f=e.length,c=0,a=0;for(r=0;ru+v||il+v||fa.index){var d=u-c.x-c.vx,p=l-c.y-c.vy,z=d*d+p*p;if(zn.r&&(n.r=n[t].r)}function initialize(){if(e){var n,r,o=e.length;i=new Array(o);for(n=0;n[c(n,t,i),n])));for(f=0,r=new Array(u);f(n=(i*n+r)%o)/o}function x$1(n){return n.x}function y$1(n){return n.y}var f=10,c=Math.PI*(3-Math.sqrt(5));function simulation(n){var i,r=1,o=.001,a=1-Math.pow(o,1/300),u=0,l=.6,s=new Map,g=e(step),h=t("tick","end"),v=lcg();null==n&&(n=[]);function step(){tick();h.call("tick",i);if(r1?(null==t?s.delete(n):s.set(n,initializeForce(t)),i):s.get(n)},find:function(t,e,i){var r,o,f,c,a,u=0,l=n.length;null==i?i=Infinity:i*=i;for(u=0;u1?(h.on(n,t),i):h.on(n)}}}function manyBody(){var t,e,i,r,o,f=constant(-30),c=1,a=Infinity,u=.81;function force(i){var o,f=t.length,c=n(t,x$1,y$1).visitAfter(accumulate);for(r=i,o=0;o=a)){if(n.data!==e||n.next){0===s&&(s=jiggle(i),v+=s*s);0===g&&(g=jiggle(i),v+=g*g);v=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function formatDecimalParts(t,r){if((i=(t=r?t.toExponential(r-1):t.toExponential()).indexOf("e"))<0)return null;var i,e=t.slice(0,i);return[e.length>1?e[0]+e.slice(2):e,+t.slice(i+1)]}function exponent(t){return t=formatDecimalParts(Math.abs(t)),t?t[1]:NaN}function formatGroup(t,r){return function(i,e){var n=i.length,a=[],o=0,c=t[0],f=0;while(n>0&&c>0){f+c+1>e&&(c=Math.max(1,e-f));a.push(i.substring(n-=c,n+c));if((f+=c+1)>e)break;c=t[o=(o+1)%t.length]}return a.reverse().join(r)}}function formatNumerals(t){return function(r){return r.replace(/[0-9]/g,(function(r){return t[+r]}))}}var t=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function formatSpecifier(r){if(!(i=t.exec(r)))throw new Error("invalid format: "+r);var i;return new FormatSpecifier({fill:i[1],align:i[2],sign:i[3],symbol:i[4],zero:i[5],width:i[6],comma:i[7],precision:i[8]&&i[8].slice(1),trim:i[9],type:i[10]})}formatSpecifier.prototype=FormatSpecifier.prototype;function FormatSpecifier(t){this.fill=void 0===t.fill?" ":t.fill+"";this.align=void 0===t.align?">":t.align+"";this.sign=void 0===t.sign?"-":t.sign+"";this.symbol=void 0===t.symbol?"":t.symbol+"";this.zero=!!t.zero;this.width=void 0===t.width?void 0:+t.width;this.comma=!!t.comma;this.precision=void 0===t.precision?void 0:+t.precision;this.trim=!!t.trim;this.type=void 0===t.type?"":t.type+""}FormatSpecifier.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};function formatTrim(t){t:for(var r,i=t.length,e=1,n=-1;e0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(r+1):t}var r;function formatPrefixAuto(t,i){var e=formatDecimalParts(t,i);if(!e)return t+"";var n=e[0],a=e[1],o=a-(r=3*Math.max(-8,Math.min(8,Math.floor(a/3))))+1,c=n.length;return o===c?n:o>c?n+new Array(o-c+1).join("0"):o>0?n.slice(0,o)+"."+n.slice(o):"0."+new Array(1-o).join("0")+formatDecimalParts(t,Math.max(0,i+o-1))[0]}function formatRounded(t,r){var i=formatDecimalParts(t,r);if(!i)return t+"";var e=i[0],n=i[1];return n<0?"0."+new Array(-n).join("0")+e:e.length>n+1?e.slice(0,n+1)+"."+e.slice(n+1):e+new Array(n-e.length+2).join("0")}var i={"%":(t,r)=>(100*t).toFixed(r),b:t=>Math.round(t).toString(2),c:t=>t+"",d:formatDecimal,e:(t,r)=>t.toExponential(r),f:(t,r)=>t.toFixed(r),g:(t,r)=>t.toPrecision(r),o:t=>Math.round(t).toString(8),p:(t,r)=>formatRounded(100*t,r),r:formatRounded,s:formatPrefixAuto,X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function identity(t){return t}var e=Array.prototype.map,n=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function formatLocale(t){var a=void 0===t.grouping||void 0===t.thousands?identity:formatGroup(e.call(t.grouping,Number),t.thousands+""),o=void 0===t.currency?"":t.currency[0]+"",c=void 0===t.currency?"":t.currency[1]+"",f=void 0===t.decimal?".":t.decimal+"",s=void 0===t.numerals?identity:formatNumerals(e.call(t.numerals,String)),m=void 0===t.percent?"%":t.percent+"",l=void 0===t.minus?"−":t.minus+"",u=void 0===t.nan?"NaN":t.nan+"";function newFormat(t){t=formatSpecifier(t);var e=t.fill,h=t.align,p=t.sign,d=t.symbol,g=t.zero,v=t.width,x=t.comma,y=t.precision,M=t.trim,b=t.type;"n"===b?(x=true,b="g"):i[b]||(void 0===y&&(y=12),M=true,b="g");(g||"0"===e&&"="===h)&&(g=true,e="0",h="=");var w="$"===d?o:"#"===d&&/[boxX]/.test(b)?"0"+b.toLowerCase():"",S="$"===d?c:/[%p]/.test(b)?m:"";var P=i[b],F=/[defgprs%]/.test(b);y=void 0===y?6:/[gprs]/.test(b)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y));function format(t){var i,o,c,m=w,d=S;if("c"===b){d=P(t)+d;t=""}else{t=+t;var k=t<0||1/t<0;t=isNaN(t)?u:P(Math.abs(t),y);M&&(t=formatTrim(t));k&&0===+t&&"+"!==p&&(k=false);m=(k?"("===p?p:l:"-"===p||"("===p?"":p)+m;d=("s"===b?n[8+r/3]:"")+d+(k&&"("===p?")":"");if(F){i=-1,o=t.length;while(++ic||c>57){d=(46===c?f+t.slice(i+1):t.slice(i))+d;t=t.slice(0,i);break}}}x&&!g&&(t=a(t,Infinity));var A=m.length+t.length+d.length,L=A>1)+m+t+d+L.slice(A);break;default:t=L+m+t+d;break}return s(t)}format.toString=function(){return t+""};return format}function formatPrefix(t,r){var i=newFormat((t=formatSpecifier(t),t.type="f",t)),e=3*Math.max(-8,Math.min(8,Math.floor(exponent(r)/3))),a=Math.pow(10,-e),o=n[8+e/3];return function(t){return i(a*t)+o}}return{format:newFormat,formatPrefix:formatPrefix}}var a;var o;var c;defaultLocale({thousands:",",grouping:[3],currency:["$",""]});function defaultLocale(t){a=formatLocale(t);o=a.format;c=a.formatPrefix;return a}function precisionFixed(t){return Math.max(0,-exponent(Math.abs(t)))}function precisionPrefix(t,r){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(exponent(r)/3)))-exponent(Math.abs(t)))}function precisionRound(t,r){t=Math.abs(t),r=Math.abs(r)-t;return Math.max(0,exponent(r)-exponent(t))+1}export{FormatSpecifier,o as format,defaultLocale as formatDefaultLocale,formatLocale,c as formatPrefix,formatSpecifier,precisionFixed,precisionPrefix,precisionRound}; diff --git a/vendor/javascript/d3-geo.js b/vendor/javascript/d3-geo.js index 016b8564..fb3cbb77 100644 --- a/vendor/javascript/d3-geo.js +++ b/vendor/javascript/d3-geo.js @@ -1,2 +1,4 @@ -import{Adder as n,merge as t,range as r}from"d3-array";var e=1e-6;var i=1e-12;var o=Math.PI;var a=o/2;var c=o/4;var u=2*o;var l=180/o;var s=o/180;var f=Math.abs;var p=Math.atan;var g=Math.atan2;var h=Math.cos;var d=Math.ceil;var v=Math.exp;Math.floor;var m=Math.hypot;var E=Math.log;var S=Math.pow;var y=Math.sin;var R=Math.sign||function(n){return n>0?1:n<0?-1:0};var w=Math.sqrt;var P=Math.tan;function acos(n){return n>1?0:n<-1?o:Math.acos(n)}function asin(n){return n>1?a:n<-1?-a:Math.asin(n)}function haversin(n){return(n=y(n/2))*n}function noop(){}function streamGeometry(n,t){n&&M.hasOwnProperty(n.type)&&M[n.type](n,t)}var j={Feature:function(n,t){streamGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e=0?1:-1,i=e*r,o=h(t),a=y(t),u=$*a,l=q*o+u*h(i),f=u*e*y(i);b.add(g(f,l));C=n,q=o,$=a}function area(t){_=new n;geoStream(t,N);return 2*_}function spherical(n){return[g(n[1],n[0]),asin(n[2])]}function cartesian(n){var t=n[0],r=n[1],e=h(r);return[e*h(t),e*y(t),y(r)]}function cartesianDot(n,t){return n[0]*t[0]+n[1]*t[1]+n[2]*t[2]}function cartesianCross(n,t){return[n[1]*t[2]-n[2]*t[1],n[2]*t[0]-n[0]*t[2],n[0]*t[1]-n[1]*t[0]]}function cartesianAddInPlace(n,t){n[0]+=t[0],n[1]+=t[1],n[2]+=t[2]}function cartesianScale(n,t){return[n[0]*t,n[1]*t,n[2]*t]}function cartesianNormalizeInPlace(n){var t=w(n[0]*n[0]+n[1]*n[1]+n[2]*n[2]);n[0]/=t,n[1]/=t,n[2]/=t}var I,A,z,F,T,U,G,k,H,W,D;var O={point:boundsPoint$1,lineStart:boundsLineStart,lineEnd:boundsLineEnd,polygonStart:function(){O.point=boundsRingPoint;O.lineStart=boundsRingStart;O.lineEnd=boundsRingEnd;H=new n;N.polygonStart()},polygonEnd:function(){N.polygonEnd();O.point=boundsPoint$1;O.lineStart=boundsLineStart;O.lineEnd=boundsLineEnd;b<0?(I=-(z=180),A=-(F=90)):H>e?F=90:H<-e&&(A=-90);D[0]=I,D[1]=z},sphere:function(){I=-(z=180),A=-(F=90)}};function boundsPoint$1(n,t){W.push(D=[I=n,z=n]);tF&&(F=t)}function linePoint(n,t){var r=cartesian([n*s,t*s]);if(k){var e=cartesianCross(k,r),i=[e[1],-e[0],0],o=cartesianCross(i,e);cartesianNormalizeInPlace(o);o=spherical(o);var a,c=n-T,u=c>0?1:-1,p=o[0]*l*u,g=f(c)>180;if(g^(u*TF&&(F=a)}else if(p=(p+360)%360-180,g^(u*TF&&(F=t)}if(g)nangle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n);else if(z>=I){nz&&(z=n)}else n>T?angle(I,n)>angle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n)}else W.push(D=[I=n,z=n]);tF&&(F=t);k=r,T=n}function boundsLineStart(){O.point=linePoint}function boundsLineEnd(){D[0]=I,D[1]=z;O.point=boundsPoint$1;k=null}function boundsRingPoint(n,t){if(k){var r=n-T;H.add(f(r)>180?r+(r>0?360:-360):r)}else U=n,G=t;N.point(n,t);linePoint(n,t)}function boundsRingStart(){N.lineStart()}function boundsRingEnd(){boundsRingPoint(U,G);N.lineEnd();f(H)>e&&(I=-(z=180));D[0]=I,D[1]=z;k=null}function angle(n,t){return(t-=n)<0?t+360:t}function rangeCompare(n,t){return n[0]-t[0]}function rangeContains(n,t){return n[0]<=n[1]?n[0]<=t&&t<=n[1]:tangle(e[0],e[1])&&(e[1]=i[1]);angle(i[0],e[1])>angle(e[0],e[1])&&(e[0]=i[0])}else o.push(e=i)}for(a=-Infinity,r=o.length-1,t=0,e=o[r];t<=r;e=i,++t){i=o[t];(c=angle(e[1],i[0]))>a&&(a=c,I=i[0],z=e[1])}}W=D=null;return Infinity===I||Infinity===A?[[NaN,NaN],[NaN,NaN]]:[[I,A],[z,F]]}var X,Y,B,Z,J,K,Q,V,nn,tn,rn,en,on,an,cn,un;var ln={sphere:noop,point:centroidPoint$1,lineStart:centroidLineStart$1,lineEnd:centroidLineEnd$1,polygonStart:function(){ln.lineStart=centroidRingStart$1;ln.lineEnd=centroidRingEnd$1},polygonEnd:function(){ln.lineStart=centroidLineStart$1;ln.lineEnd=centroidLineEnd$1}};function centroidPoint$1(n,t){n*=s,t*=s;var r=h(t);centroidPointCartesian(r*h(n),r*y(n),y(t))}function centroidPointCartesian(n,t,r){++X;B+=(n-B)/X;Z+=(t-Z)/X;J+=(r-J)/X}function centroidLineStart$1(){ln.point=centroidLinePointFirst}function centroidLinePointFirst(n,t){n*=s,t*=s;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);ln.point=centroidLinePoint;centroidPointCartesian(an,cn,un)}function centroidLinePoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=g(w((a=cn*o-un*i)*a+(a=un*e-an*o)*a+(a=an*i-cn*e)*a),an*e+cn*i+un*o);Y+=a;K+=a*(an+(an=e));Q+=a*(cn+(cn=i));V+=a*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroidLineEnd$1(){ln.point=centroidPoint$1}function centroidRingStart$1(){ln.point=centroidRingPointFirst}function centroidRingEnd$1(){centroidRingPoint(en,on);ln.point=centroidPoint$1}function centroidRingPointFirst(n,t){en=n,on=t;n*=s,t*=s;ln.point=centroidRingPoint;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);centroidPointCartesian(an,cn,un)}function centroidRingPoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=cn*o-un*i,c=un*e-an*o,u=an*i-cn*e,l=m(a,c,u),f=asin(l),p=l&&-f/l;nn.add(p*a);tn.add(p*c);rn.add(p*u);Y+=f;K+=f*(an+(an=e));Q+=f*(cn+(cn=i));V+=f*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroid(t){X=Y=B=Z=J=K=Q=V=0;nn=new n;tn=new n;rn=new n;geoStream(t,ln);var r=+nn,o=+tn,a=+rn,c=m(r,o,a);if(co&&(n-=Math.round(n/u)*u);return[n,t]}rotationIdentity.invert=rotationIdentity;function rotateRadians(n,t,r){return(n%=u)?t||r?compose(rotationLambda(n),rotationPhiGamma(t,r)):rotationLambda(n):t||r?rotationPhiGamma(t,r):rotationIdentity}function forwardRotationLambda(n){return function(t,r){t+=n;f(t)>o&&(t-=Math.round(t/u)*u);return[t,r]}}function rotationLambda(n){var t=forwardRotationLambda(n);t.invert=forwardRotationLambda(-n);return t}function rotationPhiGamma(n,t){var r=h(n),e=y(n),i=h(t),o=y(t);function rotation(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*r+c*e;return[g(u*i-s*o,c*r-l*e),asin(s*i+u*o)]}rotation.invert=function(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*i-u*o;return[g(u*i+l*o,c*r+s*e),asin(s*r-c*e)]};return rotation}function rotation(n){n=rotateRadians(n[0]*s,n[1]*s,n.length>2?n[2]*s:0);function forward(t){t=n(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t}forward.invert=function(t){t=n.invert(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t};return forward}function circleStream(n,t,r,e,i,o){if(r){var a=h(t),c=y(t),l=e*r;if(null==i){i=t+e*u;o=t-l/2}else{i=circleRadius(a,i);o=circleRadius(a,o);(e>0?io)&&(i+=e*u)}for(var s,f=i;e>0?f>o:f1&&t.push(t.pop().concat(t.shift()))},result:function(){var r=t;t=[];n=null;return r}}}function pointEqual(n,t){return f(n[0]-t[0])=0;--a)o.point((f=s[a])[0],f[1])}else i(g.x,g.p.x,-1,o);g=g.p}g=g.o;s=g.z;h=!h}while(!g.v);o.lineEnd()}}}function link(n){if(t=n.length){var t,r,e=0,i=n[0];while(++e=0?1:-1,z=A*I,F=z>o,T=b*_;m.add(g(T*A*y(z),L*N+T*h(z)));d+=F?I+A*u:I;if(F^j>=l^q>=l){var U=cartesianCross(cartesian(P),cartesian(C));cartesianNormalizeInPlace(U);var G=cartesianCross(p,U);cartesianNormalizeInPlace(G);var k=(F^I>=0?-1:1)*asin(G[2]);(s>k||s===k&&(U[0]||U[1]))&&(v+=F^I>=0?1:-1)}}}return(d<-e||d0){p||(o.polygonStart(),p=true);o.lineStart();for(n=0;n1&&2&i&&l.push(l.pop().concat(l.shift()));c.push(l.filter(validSegment))}}return g}}function validSegment(n){return n.length>1}function compareIntersection(n,t){return((n=n.x)[0]<0?n[1]-a-e:a-n[1])-((t=t.x)[0]<0?t[1]-a-e:a-t[1])}var sn=clip((function(){return true}),clipAntimeridianLine,clipAntimeridianInterpolate,[-o,-a]);function clipAntimeridianLine(n){var t,r=NaN,i=NaN,c=NaN;return{lineStart:function(){n.lineStart();t=1},point:function(u,l){var s=u>0?o:-o,p=f(u-r);if(f(p-o)0?a:-a);n.point(c,i);n.lineEnd();n.lineStart();n.point(s,i);n.point(u,i);t=0}else if(c!==s&&p>=o){f(r-c)e?p((y(t)*(a=h(i))*y(r)-y(i)*(o=h(t))*y(n))/(o*a*c)):(t+i)/2}function clipAntimeridianInterpolate(n,t,r,i){var c;if(null==n){c=r*a;i.point(-o,c);i.point(0,c);i.point(o,c);i.point(o,0);i.point(o,-c);i.point(0,-c);i.point(-o,-c);i.point(-o,0);i.point(-o,c)}else if(f(n[0]-t[0])>e){var u=n[0]0,a=f(t)>e;function interpolate(t,e,i,o){circleStream(o,n,r,i,t,e)}function visible(n,r){return h(n)*h(r)>t}function clipLine(n){var t,r,e,c,u;return{lineStart:function(){c=e=false;u=1},point:function(l,s){var f,p=[l,s],g=visible(l,s),h=i?g?0:code(l,s):g?code(l+(l<0?o:-o),s):0;!t&&(c=e=g)&&n.lineStart();if(g!==e){f=intersect(t,p);(!f||pointEqual(t,f)||pointEqual(p,f))&&(p[2]=1)}if(g!==e){u=0;if(g){n.lineStart();f=intersect(p,t);n.point(f[0],f[1])}else{f=intersect(t,p);n.point(f[0],f[1],2);n.lineEnd()}t=f}else if(a&&t&&i^g){var d;if(!(h&r)&&(d=intersect(p,t,true))){u=0;if(i){n.lineStart();n.point(d[0][0],d[0][1]);n.point(d[1][0],d[1][1]);n.lineEnd()}else{n.point(d[1][0],d[1][1]);n.lineEnd();n.lineStart();n.point(d[0][0],d[0][1],3)}}}!g||t&&pointEqual(t,p)||n.point(p[0],p[1]);t=p,e=g,r=h},lineEnd:function(){e&&n.lineEnd();t=null},clean:function(){return u|(c&&e)<<1}}}function intersect(n,r,i){var a=cartesian(n),c=cartesian(r);var u=[1,0,0],l=cartesianCross(a,c),s=cartesianDot(l,l),p=l[0],g=s-p*p;if(!g)return!i&&n;var h=t*s/g,d=-t*p/g,v=cartesianCross(u,l),m=cartesianScale(u,h),E=cartesianScale(l,d);cartesianAddInPlace(m,E);var S=v,y=cartesianDot(m,S),R=cartesianDot(S,S),P=y*y-R*(cartesianDot(m,m)-1);if(!(P<0)){var j=w(P),M=cartesianScale(S,(-y-j)/R);cartesianAddInPlace(M,m);M=spherical(M);if(!i)return M;var b,L=n[0],x=r[0],C=n[1],q=r[1];x0^M[1]<(f(M[0]-L)o^(L<=M[0]&&M[0]<=x)){var I=cartesianScale(S,(-y+j)/R);cartesianAddInPlace(I,m);return[M,spherical(I)]}}}function code(t,r){var e=i?n:o-n,a=0;t<-e?a|=1:t>e&&(a|=2);r<-e?a|=4:r>e&&(a|=8);return a}return clip(visible,clipLine,interpolate,i?[0,-n]:[-o,n-o])}function clipLine(n,t,r,e,i,o){var a,c=n[0],u=n[1],l=t[0],s=t[1],f=0,p=1,g=l-c,h=s-u;a=r-c;if(g||!(a>0)){a/=g;if(g<0){if(a0){if(a>p)return;a>f&&(f=a)}a=i-c;if(g||!(a<0)){a/=g;if(g<0){if(a>p)return;a>f&&(f=a)}else if(g>0){if(a0)){a/=h;if(h<0){if(a0){if(a>p)return;a>f&&(f=a)}a=o-u;if(h||!(a<0)){a/=h;if(h<0){if(a>p)return;a>f&&(f=a)}else if(h>0){if(a0&&(n[0]=c+f*g,n[1]=u+f*h);p<1&&(t[0]=c+p*g,t[1]=u+p*h);return true}}}}}var fn=1e9,pn=-fn;function clipRectangle(n,r,i,o){function visible(t,e){return n<=t&&t<=i&&r<=e&&e<=o}function interpolate(t,e,a,c){var u=0,l=0;if(null==t||(u=corner(t,a))!==(l=corner(e,a))||comparePoint(t,e)<0^a>0)do{c.point(0===u||3===u?n:i,u>1?o:r)}while((u=(u+a+4)%4)!==l);else c.point(e[0],e[1])}function corner(t,o){return f(t[0]-n)0?0:3:f(t[0]-i)0?2:1:f(t[1]-r)0?1:0:o>0?3:2}function compareIntersection(n,t){return comparePoint(n.x,t.x)}function comparePoint(n,t){var r=corner(n,1),e=corner(t,1);return r!==e?r-e:0===r?t[1]-n[1]:1===r?n[0]-t[0]:2===r?n[1]-t[1]:t[0]-n[0]}return function(e){var a,c,u,l,s,f,p,g,h,d,v,m=e,E=clipBuffer();var S={point:point,lineStart:lineStart,lineEnd:lineEnd,polygonStart:polygonStart,polygonEnd:polygonEnd};function point(n,t){visible(n,t)&&m.point(n,t)}function polygonInside(){var t=0;for(var r=0,e=c.length;ro&&(p-i)*(o-a)>(g-a)*(n-i)&&++t:g<=o&&(p-i)*(o-a)<(g-a)*(n-i)&&--t}return t}function polygonStart(){m=E,a=[],c=[],v=true}function polygonEnd(){var n=polygonInside(),r=v&&n,i=(a=t(a)).length;if(r||i){e.polygonStart();if(r){e.lineStart();interpolate(null,null,1,e);e.lineEnd()}i&&clipRejoin(a,compareIntersection,n,interpolate,e);e.polygonEnd()}m=e,a=c=u=null}function lineStart(){S.point=linePoint;c&&c.push(u=[]);d=true;h=false;p=g=NaN}function lineEnd(){if(a){linePoint(l,s);f&&h&&E.rejoin();a.push(E.result())}S.point=point;h&&m.lineEnd()}function linePoint(t,e){var a=visible(t,e);c&&u.push([t,e]);if(d){l=t,s=e,f=a;d=false;if(a){m.lineStart();m.point(t,e)}}else if(a&&h)m.point(t,e);else{var E=[p=Math.max(pn,Math.min(fn,p)),g=Math.max(pn,Math.min(fn,g))],S=[t=Math.max(pn,Math.min(fn,t)),e=Math.max(pn,Math.min(fn,e))];if(clipLine(E,S,n,r,i,o)){if(!h){m.lineStart();m.point(E[0],E[1])}m.point(S[0],S[1]);a||m.lineEnd();v=false}else if(a){m.lineStart();m.point(t,e);v=false}}p=t,g=e,h=a}return S}}function extent(){var n,t,r,e=0,i=0,o=960,a=500;return r={stream:function(r){return n&&t===r?n:n=clipRectangle(e,i,o,a)(t=r)},extent:function(c){return arguments.length?(e=+c[0][0],i=+c[0][1],o=+c[1][0],a=+c[1][1],n=t=null,r):[[e,i],[o,a]]}}}var gn,hn,dn,vn;var mn={sphere:noop,point:noop,lineStart:lengthLineStart,lineEnd:noop,polygonStart:noop,polygonEnd:noop};function lengthLineStart(){mn.point=lengthPointFirst$1;mn.lineEnd=lengthLineEnd}function lengthLineEnd(){mn.point=mn.lineEnd=noop}function lengthPointFirst$1(n,t){n*=s,t*=s;hn=n,dn=y(t),vn=h(t);mn.point=lengthPoint$1}function lengthPoint$1(n,t){n*=s,t*=s;var r=y(t),e=h(t),i=f(n-hn),o=h(i),a=y(i),c=e*a,u=vn*r-dn*e*o,l=dn*r+vn*e*o;gn.add(g(w(c*c+u*u),l));hn=n,dn=r,vn=e}function length(t){gn=new n;geoStream(t,mn);return+gn}var En=[null,null],Sn={type:"LineString",coordinates:En};function distance(n,t){En[0]=n;En[1]=t;return length(Sn)}var yn={Feature:function(n,t){return containsGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e0){o=distance(n[a],n[a-1]);if(o>0&&r<=o&&e<=o&&(r+e-o)*(1-Math.pow((r-e)/o,2))e})).map(s)).concat(r(d(c/m)*m,a,m).filter((function(n){return f(n%S)>e})).map(p))}graticule.lines=function(){return lines().map((function(n){return{type:"LineString",coordinates:n}}))};graticule.outline=function(){return{type:"Polygon",coordinates:[g(o).concat(h(u).slice(1),g(i).reverse().slice(1),h(l).reverse().slice(1))]}};graticule.extent=function(n){return arguments.length?graticule.extentMajor(n).extentMinor(n):graticule.extentMinor()};graticule.extentMajor=function(n){if(!arguments.length)return[[o,l],[i,u]];o=+n[0][0],i=+n[1][0];l=+n[0][1],u=+n[1][1];o>i&&(n=o,o=i,i=n);l>u&&(n=l,l=u,u=n);return graticule.precision(y)};graticule.extentMinor=function(r){if(!arguments.length)return[[t,c],[n,a]];t=+r[0][0],n=+r[1][0];c=+r[0][1],a=+r[1][1];t>n&&(r=t,t=n,n=r);c>a&&(r=c,c=a,a=r);return graticule.precision(y)};graticule.step=function(n){return arguments.length?graticule.stepMajor(n).stepMinor(n):graticule.stepMinor()};graticule.stepMajor=function(n){if(!arguments.length)return[E,S];E=+n[0],S=+n[1];return graticule};graticule.stepMinor=function(n){if(!arguments.length)return[v,m];v=+n[0],m=+n[1];return graticule};graticule.precision=function(r){if(!arguments.length)return y;y=+r;s=graticuleX(c,a,90);p=graticuleY(t,n,y);g=graticuleX(l,u,90);h=graticuleY(o,i,y);return graticule};return graticule.extentMajor([[-180,-90+e],[180,90-e]]).extentMinor([[-180,-80-e],[180,80+e]])}function graticule10(){return graticule()()}function interpolate(n,t){var r=n[0]*s,e=n[1]*s,i=t[0]*s,o=t[1]*s,a=h(e),c=y(e),u=h(o),f=y(o),p=a*h(r),d=a*y(r),v=u*h(i),m=u*y(i),E=2*asin(w(haversin(o-e)+a*u*haversin(i-r))),S=y(E);var R=E?function(n){var t=y(n*=E)/S,r=y(E-n)/S,e=r*p+t*v,i=r*d+t*m,o=r*c+t*f;return[g(i,e)*l,g(o,w(e*e+i*i))*l]}:function(){return[r*l,e*l]};R.distance=E;return R}var identity$1=n=>n;var wn,Pn,jn,Mn,bn=new n,Ln=new n;var xn={point:noop,lineStart:noop,lineEnd:noop,polygonStart:function(){xn.lineStart=areaRingStart;xn.lineEnd=areaRingEnd},polygonEnd:function(){xn.lineStart=xn.lineEnd=xn.point=noop;bn.add(f(Ln));Ln=new n},result:function(){var t=bn/2;bn=new n;return t}};function areaRingStart(){xn.point=areaPointFirst}function areaPointFirst(n,t){xn.point=areaPoint;wn=jn=n,Pn=Mn=t}function areaPoint(n,t){Ln.add(Mn*n-jn*t);jn=n,Mn=t}function areaRingEnd(){areaPoint(wn,Pn)}var Cn=Infinity,qn=Cn,$n=-Cn,_n=$n;var Nn={point:boundsPoint,lineStart:noop,lineEnd:noop,polygonStart:noop,polygonEnd:noop,result:function(){var n=[[Cn,qn],[$n,_n]];$n=_n=-(qn=Cn=Infinity);return n}};function boundsPoint(n,t){n$n&&($n=n);t_n&&(_n=t)}var In,An,zn,Fn,Tn=0,Un=0,Gn=0,kn=0,Hn=0,Wn=0,Dn=0,On=0,Xn=0;var Yn={point:centroidPoint,lineStart:centroidLineStart,lineEnd:centroidLineEnd,polygonStart:function(){Yn.lineStart=centroidRingStart;Yn.lineEnd=centroidRingEnd},polygonEnd:function(){Yn.point=centroidPoint;Yn.lineStart=centroidLineStart;Yn.lineEnd=centroidLineEnd},result:function(){var n=Xn?[Dn/Xn,On/Xn]:Wn?[kn/Wn,Hn/Wn]:Gn?[Tn/Gn,Un/Gn]:[NaN,NaN];Tn=Un=Gn=kn=Hn=Wn=Dn=On=Xn=0;return n}};function centroidPoint(n,t){Tn+=n;Un+=t;++Gn}function centroidLineStart(){Yn.point=centroidPointFirstLine}function centroidPointFirstLine(n,t){Yn.point=centroidPointLine;centroidPoint(zn=n,Fn=t)}function centroidPointLine(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;centroidPoint(zn=n,Fn=t)}function centroidLineEnd(){Yn.point=centroidPoint}function centroidRingStart(){Yn.point=centroidPointFirstRing}function centroidRingEnd(){centroidPointRing(In,An)}function centroidPointFirstRing(n,t){Yn.point=centroidPointRing;centroidPoint(In=zn=n,An=Fn=t)}function centroidPointRing(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;i=Fn*n-zn*t;Dn+=i*(zn+n);On+=i*(Fn+t);Xn+=3*i;centroidPoint(zn=n,Fn=t)}function PathContext(n){this._context=n}PathContext.prototype={_radius:4.5,pointRadius:function(n){return this._radius=n,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath();this._point=NaN},point:function(n,t){switch(this._point){case 0:this._context.moveTo(n,t);this._point=1;break;case 1:this._context.lineTo(n,t);break;default:this._context.moveTo(n+this._radius,t);this._context.arc(n,t,this._radius,0,u);break}},result:noop};var Bn,Zn,Jn,Kn,Qn,Vn=new n;var nt={point:noop,lineStart:function(){nt.point=lengthPointFirst},lineEnd:function(){Bn&&lengthPoint(Zn,Jn);nt.point=noop},polygonStart:function(){Bn=true},polygonEnd:function(){Bn=null},result:function(){var t=+Vn;Vn=new n;return t}};function lengthPointFirst(n,t){nt.point=lengthPoint;Zn=Kn=n,Jn=Qn=t}function lengthPoint(n,t){Kn-=n,Qn-=t;Vn.add(w(Kn*Kn+Qn*Qn));Kn=n,Qn=t}let tt,rt,et,it;class PathString{constructor(n){this._append=null==n?append:appendRound(n);this._radius=4.5;this._=""}pointRadius(n){this._radius=+n;return this}polygonStart(){this._line=0}polygonEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){0===this._line&&(this._+="Z");this._point=NaN}point(n,t){switch(this._point){case 0:this._append`M${n},${t}`;this._point=1;break;case 1:this._append`L${n},${t}`;break;default:this._append`M${n},${t}`;if(this._radius!==et||this._append!==rt){const n=this._radius;const t=this._;this._="";this._append`m0,${n}a${n},${n} 0 1,1 0,${-2*n}a${n},${n} 0 1,1 0,${2*n}z`;et=n;rt=this._append;it=this._;this._=t}this._+=it;break}}result(){const n=this._;this._="";return n.length?n:null}}function append(n){let t=1;this._+=n[0];for(const r=n.length;t=0))throw new RangeError(`invalid digits: ${n}`);if(t>15)return append;if(t!==tt){const n=10**t;tt=t;rt=function append(t){let r=1;this._+=t[0];for(const e=t.length;r=0))throw new RangeError(`invalid digits: ${n}`);i=t}null===t&&(e=new PathString(i));return path};return path.projection(n).digits(i).context(t)}function transform(n){return{stream:transformer(n)}}function transformer(n){return function(t){var r=new TransformStream;for(var e in n)r[e]=n[e];r.stream=t;return r}}function TransformStream(){}TransformStream.prototype={constructor:TransformStream,point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};function fit(n,t,r){var e=n.clipExtent&&n.clipExtent();n.scale(150).translate([0,0]);null!=e&&n.clipExtent(null);geoStream(r,n.stream(Nn));t(Nn.result());null!=e&&n.clipExtent(e);return n}function fitExtent(n,t,r){return fit(n,(function(r){var e=t[1][0]-t[0][0],i=t[1][1]-t[0][1],o=Math.min(e/(r[1][0]-r[0][0]),i/(r[1][1]-r[0][1])),a=+t[0][0]+(e-o*(r[1][0]+r[0][0]))/2,c=+t[0][1]+(i-o*(r[1][1]+r[0][1]))/2;n.scale(150*o).translate([a,c])}),r)}function fitSize(n,t,r){return fitExtent(n,[[0,0],t],r)}function fitWidth(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][0]-r[0][0]),o=(e-i*(r[1][0]+r[0][0]))/2,a=-i*r[0][1];n.scale(150*i).translate([o,a])}),r)}function fitHeight(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][1]-r[0][1]),o=-i*r[0][0],a=(e-i*(r[1][1]+r[0][1]))/2;n.scale(150*i).translate([o,a])}),r)}var ot=16,at=h(30*s);function resample(n,t){return+t?resample$1(n,t):resampleNone(n)}function resampleNone(n){return transformer({point:function(t,r){t=n(t,r);this.stream.point(t[0],t[1])}})}function resample$1(n,t){function resampleLineTo(r,i,o,a,c,u,l,s,p,h,d,v,m,E){var S=l-r,y=s-i,R=S*S+y*y;if(R>4*t&&m--){var P=a+h,j=c+d,M=u+v,b=w(P*P+j*j+M*M),L=asin(M/=b),x=f(f(M)-1)t||f((S*_+y*N)/R-.5)>.3||a*h+c*d+u*v2?n[2]%360*s:0,recenter()):[E*l,S*l,y*l]};projection.angle=function(n){return arguments.length?(R=n%360*s,recenter()):R*l};projection.reflectX=function(n){return arguments.length?(P=n?-1:1,recenter()):P<0};projection.reflectY=function(n){return arguments.length?(j=n?-1:1,recenter()):j<0};projection.precision=function(n){return arguments.length?(a=resample(c,C=n*n),reset()):w(C)};projection.fitExtent=function(n,t){return fitExtent(projection,n,t)};projection.fitSize=function(n,t){return fitSize(projection,n,t)};projection.fitWidth=function(n,t){return fitWidth(projection,n,t)};projection.fitHeight=function(n,t){return fitHeight(projection,n,t)};function recenter(){var n=scaleTranslateRotate(g,0,0,P,j,R).apply(null,t(v,m)),e=scaleTranslateRotate(g,h-n[0],d-n[1],P,j,R);r=rotateRadians(E,S,y);c=compose(t,e);u=compose(r,c);a=resample(c,C);return reset()}function reset(){f=p=null;return projection}return function(){t=n.apply(this,arguments);projection.invert=t.invert&&invert;return recenter()}}function conicProjection(n){var t=0,r=o/3,e=projectionMutator(n),i=e(t,r);i.parallels=function(n){return arguments.length?e(t=n[0]*s,r=n[1]*s):[t*l,r*l]};return i}function cylindricalEqualAreaRaw(n){var t=h(n);function forward(n,r){return[n*t,y(r)/t]}forward.invert=function(n,r){return[n/t,asin(r*t)]};return forward}function conicEqualAreaRaw(n,t){var r=y(n),i=(r+y(t))/2;if(f(i)=.12&&i<.234&&e>=-.425&&e<-.214?u:i>=.166&&i<.234&&e>=-.214&&e<-.115?l:c).invert(n)};albersUsa.stream=function(r){return n&&t===r?n:n=multiplex([c.stream(t=r),u.stream(r),l.stream(r)])};albersUsa.precision=function(n){if(!arguments.length)return c.precision();c.precision(n),u.precision(n),l.precision(n);return reset()};albersUsa.scale=function(n){if(!arguments.length)return c.scale();c.scale(n),u.scale(.35*n),l.scale(n);return albersUsa.translate(c.translate())};albersUsa.translate=function(n){if(!arguments.length)return c.translate();var t=c.scale(),a=+n[0],f=+n[1];r=c.translate(n).clipExtent([[a-.455*t,f-.238*t],[a+.455*t,f+.238*t]]).stream(s);i=u.translate([a-.307*t,f+.201*t]).clipExtent([[a-.425*t+e,f+.12*t+e],[a-.214*t-e,f+.234*t-e]]).stream(s);o=l.translate([a-.205*t,f+.212*t]).clipExtent([[a-.214*t+e,f+.166*t+e],[a-.115*t-e,f+.234*t-e]]).stream(s);return reset()};albersUsa.fitExtent=function(n,t){return fitExtent(albersUsa,n,t)};albersUsa.fitSize=function(n,t){return fitSize(albersUsa,n,t)};albersUsa.fitWidth=function(n,t){return fitWidth(albersUsa,n,t)};albersUsa.fitHeight=function(n,t){return fitHeight(albersUsa,n,t)};function reset(){n=t=null;return albersUsa}return albersUsa.scale(1070)}function azimuthalRaw(n){return function(t,r){var e=h(t),i=h(r),o=n(e*i);return Infinity===o?[2,0]:[o*i*y(t),o*y(r)]}}function azimuthalInvert(n){return function(t,r){var e=w(t*t+r*r),i=n(e),o=y(i),a=h(i);return[g(t*o,e*a),asin(e&&r*o/e)]}}var ut=azimuthalRaw((function(n){return w(2/(1+n))}));ut.invert=azimuthalInvert((function(n){return 2*asin(n/2)}));function azimuthalEqualArea(){return projection(ut).scale(124.75).clipAngle(179.999)}var lt=azimuthalRaw((function(n){return(n=acos(n))&&n/y(n)}));lt.invert=azimuthalInvert((function(n){return n}));function azimuthalEquidistant(){return projection(lt).scale(79.4188).clipAngle(179.999)}function mercatorRaw(n,t){return[n,E(P((a+t)/2))]}mercatorRaw.invert=function(n,t){return[n,2*p(v(t))-a]};function mercator(){return mercatorProjection(mercatorRaw).scale(961/u)}function mercatorProjection(n){var t,r,e,i=projection(n),a=i.center,c=i.scale,u=i.translate,l=i.clipExtent,s=null;i.scale=function(n){return arguments.length?(c(n),reclip()):c()};i.translate=function(n){return arguments.length?(u(n),reclip()):u()};i.center=function(n){return arguments.length?(a(n),reclip()):a()};i.clipExtent=function(n){return arguments.length?(null==n?s=t=r=e=null:(s=+n[0][0],t=+n[0][1],r=+n[1][0],e=+n[1][1]),reclip()):null==s?null:[[s,t],[r,e]]};function reclip(){var a=o*c(),u=i(rotation(i.rotate()).invert([0,0]));return l(null==s?[[u[0]-a,u[1]-a],[u[0]+a,u[1]+a]]:n===mercatorRaw?[[Math.max(u[0]-a,s),t],[Math.min(u[0]+a,r),e]]:[[s,Math.max(u[1]-a,t)],[r,Math.min(u[1]+a,e)]])}return reclip()}function tany(n){return P((a+n)/2)}function conicConformalRaw(n,t){var r=h(n),i=n===t?y(n):E(r/h(t))/E(tany(t)/tany(n)),c=r*S(tany(n),i)/i;if(!i)return mercatorRaw;function project(n,t){c>0?t<-a+e&&(t=-a+e):t>a-e&&(t=a-e);var r=c/S(tany(t),i);return[r*y(i*n),c-r*h(i*n)]}project.invert=function(n,t){var r=c-t,e=R(i)*w(n*n+r*r),u=g(n,f(r))*R(r);r*i<0&&(u-=o*R(n)*R(r));return[u/i,2*p(S(c/e,1/i))-a]};return project}function conicConformal(){return conicProjection(conicConformalRaw).scale(109.5).parallels([30,30])}function equirectangularRaw(n,t){return[n,t]}equirectangularRaw.invert=equirectangularRaw;function equirectangular(){return projection(equirectangularRaw).scale(152.63)}function conicEquidistantRaw(n,t){var r=h(n),i=n===t?y(n):(r-h(t))/(t-n),a=r/i+n;if(f(i)e&&--o>0);return[n/(.8707+(a=i*i)*(a*(a*a*a*(.003971-.001529*a)-.013791)-.131979)),i]};function naturalEarth1(){return projection(naturalEarth1Raw).scale(175.295)}function orthographicRaw(n,t){return[h(t)*y(n),y(t)]}orthographicRaw.invert=azimuthalInvert(asin);function orthographic(){return projection(orthographicRaw).scale(249.5).clipAngle(90+e)}function stereographicRaw(n,t){var r=h(t),e=1+h(n)*r;return[r*y(n)/e,y(t)/e]}stereographicRaw.invert=azimuthalInvert((function(n){return 2*p(n)}));function stereographic(){return projection(stereographicRaw).scale(250).clipAngle(142)}function transverseMercatorRaw(n,t){return[E(P((a+t)/2)),-n]}transverseMercatorRaw.invert=function(n,t){return[-t,2*p(v(n))-a]};function transverseMercator(){var n=mercatorProjection(transverseMercatorRaw),t=n.center,r=n.rotate;n.center=function(n){return arguments.length?t([-n[1],n[0]]):(n=t(),[n[1],-n[0]])};n.rotate=function(n){return arguments.length?r([n[0],n[1],n.length>2?n[2]+90:90]):(n=r(),[n[0],n[1],n[2]-90])};return r([0,0,90]).scale(159.155)}export{albers as geoAlbers,albersUsa as geoAlbersUsa,area as geoArea,azimuthalEqualArea as geoAzimuthalEqualArea,ut as geoAzimuthalEqualAreaRaw,azimuthalEquidistant as geoAzimuthalEquidistant,lt as geoAzimuthalEquidistantRaw,bounds as geoBounds,centroid as geoCentroid,circle as geoCircle,sn as geoClipAntimeridian,clipCircle as geoClipCircle,extent as geoClipExtent,clipRectangle as geoClipRectangle,conicConformal as geoConicConformal,conicConformalRaw as geoConicConformalRaw,conicEqualArea as geoConicEqualArea,conicEqualAreaRaw as geoConicEqualAreaRaw,conicEquidistant as geoConicEquidistant,conicEquidistantRaw as geoConicEquidistantRaw,contains as geoContains,distance as geoDistance,equalEarth as geoEqualEarth,equalEarthRaw as geoEqualEarthRaw,equirectangular as geoEquirectangular,equirectangularRaw as geoEquirectangularRaw,gnomonic as geoGnomonic,gnomonicRaw as geoGnomonicRaw,graticule as geoGraticule,graticule10 as geoGraticule10,identity as geoIdentity,interpolate as geoInterpolate,length as geoLength,mercator as geoMercator,mercatorRaw as geoMercatorRaw,naturalEarth1 as geoNaturalEarth1,naturalEarth1Raw as geoNaturalEarth1Raw,orthographic as geoOrthographic,orthographicRaw as geoOrthographicRaw,index as geoPath,projection as geoProjection,projectionMutator as geoProjectionMutator,rotation as geoRotation,stereographic as geoStereographic,stereographicRaw as geoStereographicRaw,geoStream,transform as geoTransform,transverseMercator as geoTransverseMercator,transverseMercatorRaw as geoTransverseMercatorRaw}; +// d3-geo@3.1.1 downloaded from https://ga.jspm.io/npm:d3-geo@3.1.1/src/index.js + +import{Adder as n,merge as t,range as r}from"d3-array";var e=1e-6;var i=1e-12;var o=Math.PI;var a=o/2;var c=o/4;var u=o*2;var l=180/o;var s=o/180;var f=Math.abs;var p=Math.atan;var g=Math.atan2;var h=Math.cos;var d=Math.ceil;var v=Math.exp;Math.floor;var m=Math.hypot;var E=Math.log;var S=Math.pow;var y=Math.sin;var R=Math.sign||function(n){return n>0?1:n<0?-1:0};var w=Math.sqrt;var P=Math.tan;function acos(n){return n>1?0:n<-1?o:Math.acos(n)}function asin(n){return n>1?a:n<-1?-a:Math.asin(n)}function haversin(n){return(n=y(n/2))*n}function noop(){}function streamGeometry(n,t){n&&M.hasOwnProperty(n.type)&&M[n.type](n,t)}var j={Feature:function(n,t){streamGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e=0?1:-1,i=e*r,o=h(t),a=y(t),u=$*a,l=q*o+u*h(i),f=u*e*y(i);b.add(g(f,l));C=n,q=o,$=a}function area(t){_=new n;geoStream(t,N);return _*2}function spherical(n){return[g(n[1],n[0]),asin(n[2])]}function cartesian(n){var t=n[0],r=n[1],e=h(r);return[e*h(t),e*y(t),y(r)]}function cartesianDot(n,t){return n[0]*t[0]+n[1]*t[1]+n[2]*t[2]}function cartesianCross(n,t){return[n[1]*t[2]-n[2]*t[1],n[2]*t[0]-n[0]*t[2],n[0]*t[1]-n[1]*t[0]]}function cartesianAddInPlace(n,t){n[0]+=t[0],n[1]+=t[1],n[2]+=t[2]}function cartesianScale(n,t){return[n[0]*t,n[1]*t,n[2]*t]}function cartesianNormalizeInPlace(n){var t=w(n[0]*n[0]+n[1]*n[1]+n[2]*n[2]);n[0]/=t,n[1]/=t,n[2]/=t}var I,A,z,F,T,U,G,k,H,W,D;var O={point:boundsPoint$1,lineStart:boundsLineStart,lineEnd:boundsLineEnd,polygonStart:function(){O.point=boundsRingPoint;O.lineStart=boundsRingStart;O.lineEnd=boundsRingEnd;H=new n;N.polygonStart()},polygonEnd:function(){N.polygonEnd();O.point=boundsPoint$1;O.lineStart=boundsLineStart;O.lineEnd=boundsLineEnd;b<0?(I=-(z=180),A=-(F=90)):H>e?F=90:H<-e&&(A=-90);D[0]=I,D[1]=z},sphere:function(){I=-(z=180),A=-(F=90)}};function boundsPoint$1(n,t){W.push(D=[I=n,z=n]);tF&&(F=t)}function linePoint(n,t){var r=cartesian([n*s,t*s]);if(k){var e=cartesianCross(k,r),i=[e[1],-e[0],0],o=cartesianCross(i,e);cartesianNormalizeInPlace(o);o=spherical(o);var a,c=n-T,u=c>0?1:-1,p=o[0]*l*u,g=f(c)>180;if(g^(u*TF&&(F=a)}else if(p=(p+360)%360-180,g^(u*TF&&(F=t)}if(g)nangle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n);else if(z>=I){nz&&(z=n)}else n>T?angle(I,n)>angle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n)}else W.push(D=[I=n,z=n]);tF&&(F=t);k=r,T=n}function boundsLineStart(){O.point=linePoint}function boundsLineEnd(){D[0]=I,D[1]=z;O.point=boundsPoint$1;k=null}function boundsRingPoint(n,t){if(k){var r=n-T;H.add(f(r)>180?r+(r>0?360:-360):r)}else U=n,G=t;N.point(n,t);linePoint(n,t)}function boundsRingStart(){N.lineStart()}function boundsRingEnd(){boundsRingPoint(U,G);N.lineEnd();f(H)>e&&(I=-(z=180));D[0]=I,D[1]=z;k=null}function angle(n,t){return(t-=n)<0?t+360:t}function rangeCompare(n,t){return n[0]-t[0]}function rangeContains(n,t){return n[0]<=n[1]?n[0]<=t&&t<=n[1]:tangle(e[0],e[1])&&(e[1]=i[1]);angle(i[0],e[1])>angle(e[0],e[1])&&(e[0]=i[0])}else o.push(e=i)}for(a=-Infinity,r=o.length-1,t=0,e=o[r];t<=r;e=i,++t){i=o[t];(c=angle(e[1],i[0]))>a&&(a=c,I=i[0],z=e[1])}}W=D=null;return I===Infinity||A===Infinity?[[NaN,NaN],[NaN,NaN]]:[[I,A],[z,F]]}var X,Y,B,Z,J,K,Q,V,nn,tn,rn,en,on,an,cn,un;var ln={sphere:noop,point:centroidPoint$1,lineStart:centroidLineStart$1,lineEnd:centroidLineEnd$1,polygonStart:function(){ln.lineStart=centroidRingStart$1;ln.lineEnd=centroidRingEnd$1},polygonEnd:function(){ln.lineStart=centroidLineStart$1;ln.lineEnd=centroidLineEnd$1}};function centroidPoint$1(n,t){n*=s,t*=s;var r=h(t);centroidPointCartesian(r*h(n),r*y(n),y(t))}function centroidPointCartesian(n,t,r){++X;B+=(n-B)/X;Z+=(t-Z)/X;J+=(r-J)/X}function centroidLineStart$1(){ln.point=centroidLinePointFirst}function centroidLinePointFirst(n,t){n*=s,t*=s;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);ln.point=centroidLinePoint;centroidPointCartesian(an,cn,un)}function centroidLinePoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=g(w((a=cn*o-un*i)*a+(a=un*e-an*o)*a+(a=an*i-cn*e)*a),an*e+cn*i+un*o);Y+=a;K+=a*(an+(an=e));Q+=a*(cn+(cn=i));V+=a*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroidLineEnd$1(){ln.point=centroidPoint$1}function centroidRingStart$1(){ln.point=centroidRingPointFirst}function centroidRingEnd$1(){centroidRingPoint(en,on);ln.point=centroidPoint$1}function centroidRingPointFirst(n,t){en=n,on=t;n*=s,t*=s;ln.point=centroidRingPoint;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);centroidPointCartesian(an,cn,un)}function centroidRingPoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=cn*o-un*i,c=un*e-an*o,u=an*i-cn*e,l=m(a,c,u),f=asin(l),p=l&&-f/l;nn.add(p*a);tn.add(p*c);rn.add(p*u);Y+=f;K+=f*(an+(an=e));Q+=f*(cn+(cn=i));V+=f*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroid(t){X=Y=B=Z=J=K=Q=V=0;nn=new n;tn=new n;rn=new n;geoStream(t,ln);var r=+nn,o=+tn,a=+rn,c=m(r,o,a);if(co&&(n-=Math.round(n/u)*u);return[n,t]}rotationIdentity.invert=rotationIdentity;function rotateRadians(n,t,r){return(n%=u)?t||r?compose(rotationLambda(n),rotationPhiGamma(t,r)):rotationLambda(n):t||r?rotationPhiGamma(t,r):rotationIdentity}function forwardRotationLambda(n){return function(t,r){t+=n;f(t)>o&&(t-=Math.round(t/u)*u);return[t,r]}}function rotationLambda(n){var t=forwardRotationLambda(n);t.invert=forwardRotationLambda(-n);return t}function rotationPhiGamma(n,t){var r=h(n),e=y(n),i=h(t),o=y(t);function rotation(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*r+c*e;return[g(u*i-s*o,c*r-l*e),asin(s*i+u*o)]}rotation.invert=function(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*i-u*o;return[g(u*i+l*o,c*r+s*e),asin(s*r-c*e)]};return rotation}function rotation(n){n=rotateRadians(n[0]*s,n[1]*s,n.length>2?n[2]*s:0);function forward(t){t=n(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t}forward.invert=function(t){t=n.invert(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t};return forward}function circleStream(n,t,r,e,i,o){if(r){var a=h(t),c=y(t),l=e*r;if(i==null){i=t+e*u;o=t-l/2}else{i=circleRadius(a,i);o=circleRadius(a,o);(e>0?io)&&(i+=e*u)}for(var s,f=i;e>0?f>o:f1&&t.push(t.pop().concat(t.shift()))},result:function(){var r=t;t=[];n=null;return r}}}function pointEqual(n,t){return f(n[0]-t[0])=0;--a)o.point((f=s[a])[0],f[1])}else i(g.x,g.p.x,-1,o);g=g.p}g=g.o;s=g.z;h=!h}while(!g.v);o.lineEnd()}}}function link(n){if(t=n.length){var t,r,e=0,i=n[0];while(++e=0?1:-1,z=A*I,F=z>o,T=b*_;m.add(g(T*A*y(z),L*N+T*h(z)));d+=F?I+A*u:I;if(F^j>=l^q>=l){var U=cartesianCross(cartesian(P),cartesian(C));cartesianNormalizeInPlace(U);var G=cartesianCross(p,U);cartesianNormalizeInPlace(G);var k=(F^I>=0?-1:1)*asin(G[2]);(s>k||s===k&&(U[0]||U[1]))&&(v+=F^I>=0?1:-1)}}}return(d<-e||d0){p||(o.polygonStart(),p=true);o.lineStart();for(n=0;n1&&i&2&&l.push(l.pop().concat(l.shift()));c.push(l.filter(validSegment))}}return g}}function validSegment(n){return n.length>1}function compareIntersection(n,t){return((n=n.x)[0]<0?n[1]-a-e:a-n[1])-((t=t.x)[0]<0?t[1]-a-e:a-t[1])}var sn=clip((function(){return true}),clipAntimeridianLine,clipAntimeridianInterpolate,[-o,-a]);function clipAntimeridianLine(n){var t,r=NaN,i=NaN,c=NaN;return{lineStart:function(){n.lineStart();t=1},point:function(u,l){var s=u>0?o:-o,p=f(u-r);if(f(p-o)0?a:-a);n.point(c,i);n.lineEnd();n.lineStart();n.point(s,i);n.point(u,i);t=0}else if(c!==s&&p>=o){f(r-c)e?p((y(t)*(a=h(i))*y(r)-y(i)*(o=h(t))*y(n))/(o*a*c)):(t+i)/2}function clipAntimeridianInterpolate(n,t,r,i){var c;if(n==null){c=r*a;i.point(-o,c);i.point(0,c);i.point(o,c);i.point(o,0);i.point(o,-c);i.point(0,-c);i.point(-o,-c);i.point(-o,0);i.point(-o,c)}else if(f(n[0]-t[0])>e){var u=n[0]0,a=f(t)>e;function interpolate(t,e,i,o){circleStream(o,n,r,i,t,e)}function visible(n,r){return h(n)*h(r)>t}function clipLine(n){var t,r,e,c,u;return{lineStart:function(){c=e=false;u=1},point:function(l,s){var f,p=[l,s],g=visible(l,s),h=i?g?0:code(l,s):g?code(l+(l<0?o:-o),s):0;!t&&(c=e=g)&&n.lineStart();if(g!==e){f=intersect(t,p);(!f||pointEqual(t,f)||pointEqual(p,f))&&(p[2]=1)}if(g!==e){u=0;if(g){n.lineStart();f=intersect(p,t);n.point(f[0],f[1])}else{f=intersect(t,p);n.point(f[0],f[1],2);n.lineEnd()}t=f}else if(a&&t&&i^g){var d;if(!(h&r)&&(d=intersect(p,t,true))){u=0;if(i){n.lineStart();n.point(d[0][0],d[0][1]);n.point(d[1][0],d[1][1]);n.lineEnd()}else{n.point(d[1][0],d[1][1]);n.lineEnd();n.lineStart();n.point(d[0][0],d[0][1],3)}}}!g||t&&pointEqual(t,p)||n.point(p[0],p[1]);t=p,e=g,r=h},lineEnd:function(){e&&n.lineEnd();t=null},clean:function(){return u|(c&&e)<<1}}}function intersect(n,r,i){var a=cartesian(n),c=cartesian(r);var u=[1,0,0],l=cartesianCross(a,c),s=cartesianDot(l,l),p=l[0],g=s-p*p;if(!g)return!i&&n;var h=t*s/g,d=-t*p/g,v=cartesianCross(u,l),m=cartesianScale(u,h),E=cartesianScale(l,d);cartesianAddInPlace(m,E);var S=v,y=cartesianDot(m,S),R=cartesianDot(S,S),P=y*y-R*(cartesianDot(m,m)-1);if(!(P<0)){var j=w(P),M=cartesianScale(S,(-y-j)/R);cartesianAddInPlace(M,m);M=spherical(M);if(!i)return M;var b,L=n[0],x=r[0],C=n[1],q=r[1];x0^M[1]<(f(M[0]-L)o^(L<=M[0]&&M[0]<=x)){var I=cartesianScale(S,(-y+j)/R);cartesianAddInPlace(I,m);return[M,spherical(I)]}}}function code(t,r){var e=i?n:o-n,a=0;t<-e?a|=1:t>e&&(a|=2);r<-e?a|=4:r>e&&(a|=8);return a}return clip(visible,clipLine,interpolate,i?[0,-n]:[-o,n-o])}function clipLine(n,t,r,e,i,o){var a,c=n[0],u=n[1],l=t[0],s=t[1],f=0,p=1,g=l-c,h=s-u;a=r-c;if(g||!(a>0)){a/=g;if(g<0){if(a0){if(a>p)return;a>f&&(f=a)}a=i-c;if(g||!(a<0)){a/=g;if(g<0){if(a>p)return;a>f&&(f=a)}else if(g>0){if(a0)){a/=h;if(h<0){if(a0){if(a>p)return;a>f&&(f=a)}a=o-u;if(h||!(a<0)){a/=h;if(h<0){if(a>p)return;a>f&&(f=a)}else if(h>0){if(a0&&(n[0]=c+f*g,n[1]=u+f*h);p<1&&(t[0]=c+p*g,t[1]=u+p*h);return true}}}}}var fn=1e9,pn=-fn;function clipRectangle(n,r,i,o){function visible(t,e){return n<=t&&t<=i&&r<=e&&e<=o}function interpolate(t,e,a,c){var u=0,l=0;if(t==null||(u=corner(t,a))!==(l=corner(e,a))||comparePoint(t,e)<0^a>0)do{c.point(u===0||u===3?n:i,u>1?o:r)}while((u=(u+a+4)%4)!==l);else c.point(e[0],e[1])}function corner(t,o){return f(t[0]-n)0?0:3:f(t[0]-i)0?2:1:f(t[1]-r)0?1:0:o>0?3:2}function compareIntersection(n,t){return comparePoint(n.x,t.x)}function comparePoint(n,t){var r=corner(n,1),e=corner(t,1);return r!==e?r-e:r===0?t[1]-n[1]:r===1?n[0]-t[0]:r===2?n[1]-t[1]:t[0]-n[0]}return function(e){var a,c,u,l,s,f,p,g,h,d,v,m=e,E=clipBuffer();var S={point:point,lineStart:lineStart,lineEnd:lineEnd,polygonStart:polygonStart,polygonEnd:polygonEnd};function point(n,t){visible(n,t)&&m.point(n,t)}function polygonInside(){var t=0;for(var r=0,e=c.length;ro&&(p-i)*(o-a)>(g-a)*(n-i)&&++t:g<=o&&(p-i)*(o-a)<(g-a)*(n-i)&&--t}return t}function polygonStart(){m=E,a=[],c=[],v=true}function polygonEnd(){var n=polygonInside(),r=v&&n,i=(a=t(a)).length;if(r||i){e.polygonStart();if(r){e.lineStart();interpolate(null,null,1,e);e.lineEnd()}i&&clipRejoin(a,compareIntersection,n,interpolate,e);e.polygonEnd()}m=e,a=c=u=null}function lineStart(){S.point=linePoint;c&&c.push(u=[]);d=true;h=false;p=g=NaN}function lineEnd(){if(a){linePoint(l,s);f&&h&&E.rejoin();a.push(E.result())}S.point=point;h&&m.lineEnd()}function linePoint(t,e){var a=visible(t,e);c&&u.push([t,e]);if(d){l=t,s=e,f=a;d=false;if(a){m.lineStart();m.point(t,e)}}else if(a&&h)m.point(t,e);else{var E=[p=Math.max(pn,Math.min(fn,p)),g=Math.max(pn,Math.min(fn,g))],S=[t=Math.max(pn,Math.min(fn,t)),e=Math.max(pn,Math.min(fn,e))];if(clipLine(E,S,n,r,i,o)){if(!h){m.lineStart();m.point(E[0],E[1])}m.point(S[0],S[1]);a||m.lineEnd();v=false}else if(a){m.lineStart();m.point(t,e);v=false}}p=t,g=e,h=a}return S}}function extent(){var n,t,r,e=0,i=0,o=960,a=500;return r={stream:function(r){return n&&t===r?n:n=clipRectangle(e,i,o,a)(t=r)},extent:function(c){return arguments.length?(e=+c[0][0],i=+c[0][1],o=+c[1][0],a=+c[1][1],n=t=null,r):[[e,i],[o,a]]}}}var gn,hn,dn,vn;var mn={sphere:noop,point:noop,lineStart:lengthLineStart,lineEnd:noop,polygonStart:noop,polygonEnd:noop};function lengthLineStart(){mn.point=lengthPointFirst$1;mn.lineEnd=lengthLineEnd}function lengthLineEnd(){mn.point=mn.lineEnd=noop}function lengthPointFirst$1(n,t){n*=s,t*=s;hn=n,dn=y(t),vn=h(t);mn.point=lengthPoint$1}function lengthPoint$1(n,t){n*=s,t*=s;var r=y(t),e=h(t),i=f(n-hn),o=h(i),a=y(i),c=e*a,u=vn*r-dn*e*o,l=dn*r+vn*e*o;gn.add(g(w(c*c+u*u),l));hn=n,dn=r,vn=e}function length(t){gn=new n;geoStream(t,mn);return+gn}var En=[null,null],Sn={type:"LineString",coordinates:En};function distance(n,t){En[0]=n;En[1]=t;return length(Sn)}var yn={Feature:function(n,t){return containsGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e0){o=distance(n[a],n[a-1]);if(o>0&&r<=o&&e<=o&&(r+e-o)*(1-Math.pow((r-e)/o,2))e})).map(s)).concat(r(d(c/m)*m,a,m).filter((function(n){return f(n%S)>e})).map(p))}graticule.lines=function(){return lines().map((function(n){return{type:"LineString",coordinates:n}}))};graticule.outline=function(){return{type:"Polygon",coordinates:[g(o).concat(h(u).slice(1),g(i).reverse().slice(1),h(l).reverse().slice(1))]}};graticule.extent=function(n){return arguments.length?graticule.extentMajor(n).extentMinor(n):graticule.extentMinor()};graticule.extentMajor=function(n){if(!arguments.length)return[[o,l],[i,u]];o=+n[0][0],i=+n[1][0];l=+n[0][1],u=+n[1][1];o>i&&(n=o,o=i,i=n);l>u&&(n=l,l=u,u=n);return graticule.precision(y)};graticule.extentMinor=function(r){if(!arguments.length)return[[t,c],[n,a]];t=+r[0][0],n=+r[1][0];c=+r[0][1],a=+r[1][1];t>n&&(r=t,t=n,n=r);c>a&&(r=c,c=a,a=r);return graticule.precision(y)};graticule.step=function(n){return arguments.length?graticule.stepMajor(n).stepMinor(n):graticule.stepMinor()};graticule.stepMajor=function(n){if(!arguments.length)return[E,S];E=+n[0],S=+n[1];return graticule};graticule.stepMinor=function(n){if(!arguments.length)return[v,m];v=+n[0],m=+n[1];return graticule};graticule.precision=function(r){if(!arguments.length)return y;y=+r;s=graticuleX(c,a,90);p=graticuleY(t,n,y);g=graticuleX(l,u,90);h=graticuleY(o,i,y);return graticule};return graticule.extentMajor([[-180,-90+e],[180,90-e]]).extentMinor([[-180,-80-e],[180,80+e]])}function graticule10(){return graticule()()}function interpolate(n,t){var r=n[0]*s,e=n[1]*s,i=t[0]*s,o=t[1]*s,a=h(e),c=y(e),u=h(o),f=y(o),p=a*h(r),d=a*y(r),v=u*h(i),m=u*y(i),E=2*asin(w(haversin(o-e)+a*u*haversin(i-r))),S=y(E);var R=E?function(n){var t=y(n*=E)/S,r=y(E-n)/S,e=r*p+t*v,i=r*d+t*m,o=r*c+t*f;return[g(i,e)*l,g(o,w(e*e+i*i))*l]}:function(){return[r*l,e*l]};R.distance=E;return R}var identity$1=n=>n;var wn,Pn,jn,Mn,bn=new n,Ln=new n;var xn={point:noop,lineStart:noop,lineEnd:noop,polygonStart:function(){xn.lineStart=areaRingStart;xn.lineEnd=areaRingEnd},polygonEnd:function(){xn.lineStart=xn.lineEnd=xn.point=noop;bn.add(f(Ln));Ln=new n},result:function(){var t=bn/2;bn=new n;return t}};function areaRingStart(){xn.point=areaPointFirst}function areaPointFirst(n,t){xn.point=areaPoint;wn=jn=n,Pn=Mn=t}function areaPoint(n,t){Ln.add(Mn*n-jn*t);jn=n,Mn=t}function areaRingEnd(){areaPoint(wn,Pn)}var Cn=Infinity,qn=Cn,$n=-Cn,_n=$n;var Nn={point:boundsPoint,lineStart:noop,lineEnd:noop,polygonStart:noop,polygonEnd:noop,result:function(){var n=[[Cn,qn],[$n,_n]];$n=_n=-(qn=Cn=Infinity);return n}};function boundsPoint(n,t){n$n&&($n=n);t_n&&(_n=t)}var In,An,zn,Fn,Tn=0,Un=0,Gn=0,kn=0,Hn=0,Wn=0,Dn=0,On=0,Xn=0;var Yn={point:centroidPoint,lineStart:centroidLineStart,lineEnd:centroidLineEnd,polygonStart:function(){Yn.lineStart=centroidRingStart;Yn.lineEnd=centroidRingEnd},polygonEnd:function(){Yn.point=centroidPoint;Yn.lineStart=centroidLineStart;Yn.lineEnd=centroidLineEnd},result:function(){var n=Xn?[Dn/Xn,On/Xn]:Wn?[kn/Wn,Hn/Wn]:Gn?[Tn/Gn,Un/Gn]:[NaN,NaN];Tn=Un=Gn=kn=Hn=Wn=Dn=On=Xn=0;return n}};function centroidPoint(n,t){Tn+=n;Un+=t;++Gn}function centroidLineStart(){Yn.point=centroidPointFirstLine}function centroidPointFirstLine(n,t){Yn.point=centroidPointLine;centroidPoint(zn=n,Fn=t)}function centroidPointLine(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;centroidPoint(zn=n,Fn=t)}function centroidLineEnd(){Yn.point=centroidPoint}function centroidRingStart(){Yn.point=centroidPointFirstRing}function centroidRingEnd(){centroidPointRing(In,An)}function centroidPointFirstRing(n,t){Yn.point=centroidPointRing;centroidPoint(In=zn=n,An=Fn=t)}function centroidPointRing(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;i=Fn*n-zn*t;Dn+=i*(zn+n);On+=i*(Fn+t);Xn+=i*3;centroidPoint(zn=n,Fn=t)}function PathContext(n){this._context=n}PathContext.prototype={_radius:4.5,pointRadius:function(n){return this._radius=n,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){this._line===0&&this._context.closePath();this._point=NaN},point:function(n,t){switch(this._point){case 0:this._context.moveTo(n,t);this._point=1;break;case 1:this._context.lineTo(n,t);break;default:this._context.moveTo(n+this._radius,t);this._context.arc(n,t,this._radius,0,u);break}},result:noop};var Bn,Zn,Jn,Kn,Qn,Vn=new n;var nt={point:noop,lineStart:function(){nt.point=lengthPointFirst},lineEnd:function(){Bn&&lengthPoint(Zn,Jn);nt.point=noop},polygonStart:function(){Bn=true},polygonEnd:function(){Bn=null},result:function(){var t=+Vn;Vn=new n;return t}};function lengthPointFirst(n,t){nt.point=lengthPoint;Zn=Kn=n,Jn=Qn=t}function lengthPoint(n,t){Kn-=n,Qn-=t;Vn.add(w(Kn*Kn+Qn*Qn));Kn=n,Qn=t}let tt,rt,et,it;class PathString{constructor(n){this._append=n==null?append:appendRound(n);this._radius=4.5;this._=""}pointRadius(n){this._radius=+n;return this}polygonStart(){this._line=0}polygonEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){this._line===0&&(this._+="Z");this._point=NaN}point(n,t){switch(this._point){case 0:this._append`M${n},${t}`;this._point=1;break;case 1:this._append`L${n},${t}`;break;default:this._append`M${n},${t}`;if(this._radius!==et||this._append!==rt){const n=this._radius;const t=this._;this._="";this._append`m0,${n}a${n},${n} 0 1,1 0,${-2*n}a${n},${n} 0 1,1 0,${2*n}z`;et=n;rt=this._append;it=this._;this._=t}this._+=it;break}}result(){const n=this._;this._="";return n.length?n:null}}function append(n){let t=1;this._+=n[0];for(const r=n.length;t=0))throw new RangeError(`invalid digits: ${n}`);if(t>15)return append;if(t!==tt){const n=10**t;tt=t;rt=function append(t){let r=1;this._+=t[0];for(const e=t.length;r=0))throw new RangeError(`invalid digits: ${n}`);i=t}t===null&&(e=new PathString(i));return path};return path.projection(n).digits(i).context(t)}function transform(n){return{stream:transformer(n)}}function transformer(n){return function(t){var r=new TransformStream;for(var e in n)r[e]=n[e];r.stream=t;return r}}function TransformStream(){}TransformStream.prototype={constructor:TransformStream,point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};function fit(n,t,r){var e=n.clipExtent&&n.clipExtent();n.scale(150).translate([0,0]);e!=null&&n.clipExtent(null);geoStream(r,n.stream(Nn));t(Nn.result());e!=null&&n.clipExtent(e);return n}function fitExtent(n,t,r){return fit(n,(function(r){var e=t[1][0]-t[0][0],i=t[1][1]-t[0][1],o=Math.min(e/(r[1][0]-r[0][0]),i/(r[1][1]-r[0][1])),a=+t[0][0]+(e-o*(r[1][0]+r[0][0]))/2,c=+t[0][1]+(i-o*(r[1][1]+r[0][1]))/2;n.scale(150*o).translate([a,c])}),r)}function fitSize(n,t,r){return fitExtent(n,[[0,0],t],r)}function fitWidth(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][0]-r[0][0]),o=(e-i*(r[1][0]+r[0][0]))/2,a=-i*r[0][1];n.scale(150*i).translate([o,a])}),r)}function fitHeight(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][1]-r[0][1]),o=-i*r[0][0],a=(e-i*(r[1][1]+r[0][1]))/2;n.scale(150*i).translate([o,a])}),r)}var ot=16,at=h(30*s);function resample(n,t){return+t?resample$1(n,t):resampleNone(n)}function resampleNone(n){return transformer({point:function(t,r){t=n(t,r);this.stream.point(t[0],t[1])}})}function resample$1(n,t){function resampleLineTo(r,i,o,a,c,u,l,s,p,h,d,v,m,E){var S=l-r,y=s-i,R=S*S+y*y;if(R>4*t&&m--){var P=a+h,j=c+d,M=u+v,b=w(P*P+j*j+M*M),L=asin(M/=b),x=f(f(M)-1)t||f((S*_+y*N)/R-.5)>.3||a*h+c*d+u*v2?n[2]%360*s:0,recenter()):[E*l,S*l,y*l]};projection.angle=function(n){return arguments.length?(R=n%360*s,recenter()):R*l};projection.reflectX=function(n){return arguments.length?(P=n?-1:1,recenter()):P<0};projection.reflectY=function(n){return arguments.length?(j=n?-1:1,recenter()):j<0};projection.precision=function(n){return arguments.length?(a=resample(c,C=n*n),reset()):w(C)};projection.fitExtent=function(n,t){return fitExtent(projection,n,t)};projection.fitSize=function(n,t){return fitSize(projection,n,t)};projection.fitWidth=function(n,t){return fitWidth(projection,n,t)};projection.fitHeight=function(n,t){return fitHeight(projection,n,t)};function recenter(){var n=scaleTranslateRotate(g,0,0,P,j,R).apply(null,t(v,m)),e=scaleTranslateRotate(g,h-n[0],d-n[1],P,j,R);r=rotateRadians(E,S,y);c=compose(t,e);u=compose(r,c);a=resample(c,C);return reset()}function reset(){f=p=null;return projection}return function(){t=n.apply(this,arguments);projection.invert=t.invert&&invert;return recenter()}}function conicProjection(n){var t=0,r=o/3,e=projectionMutator(n),i=e(t,r);i.parallels=function(n){return arguments.length?e(t=n[0]*s,r=n[1]*s):[t*l,r*l]};return i}function cylindricalEqualAreaRaw(n){var t=h(n);function forward(n,r){return[n*t,y(r)/t]}forward.invert=function(n,r){return[n/t,asin(r*t)]};return forward}function conicEqualAreaRaw(n,t){var r=y(n),i=(r+y(t))/2;if(f(i)=.12&&i<.234&&e>=-.425&&e<-.214?u:i>=.166&&i<.234&&e>=-.214&&e<-.115?l:c).invert(n)};albersUsa.stream=function(r){return n&&t===r?n:n=multiplex([c.stream(t=r),u.stream(r),l.stream(r)])};albersUsa.precision=function(n){if(!arguments.length)return c.precision();c.precision(n),u.precision(n),l.precision(n);return reset()};albersUsa.scale=function(n){if(!arguments.length)return c.scale();c.scale(n),u.scale(n*.35),l.scale(n);return albersUsa.translate(c.translate())};albersUsa.translate=function(n){if(!arguments.length)return c.translate();var t=c.scale(),a=+n[0],f=+n[1];r=c.translate(n).clipExtent([[a-.455*t,f-.238*t],[a+.455*t,f+.238*t]]).stream(s);i=u.translate([a-.307*t,f+.201*t]).clipExtent([[a-.425*t+e,f+.12*t+e],[a-.214*t-e,f+.234*t-e]]).stream(s);o=l.translate([a-.205*t,f+.212*t]).clipExtent([[a-.214*t+e,f+.166*t+e],[a-.115*t-e,f+.234*t-e]]).stream(s);return reset()};albersUsa.fitExtent=function(n,t){return fitExtent(albersUsa,n,t)};albersUsa.fitSize=function(n,t){return fitSize(albersUsa,n,t)};albersUsa.fitWidth=function(n,t){return fitWidth(albersUsa,n,t)};albersUsa.fitHeight=function(n,t){return fitHeight(albersUsa,n,t)};function reset(){n=t=null;return albersUsa}return albersUsa.scale(1070)}function azimuthalRaw(n){return function(t,r){var e=h(t),i=h(r),o=n(e*i);return o===Infinity?[2,0]:[o*i*y(t),o*y(r)]}}function azimuthalInvert(n){return function(t,r){var e=w(t*t+r*r),i=n(e),o=y(i),a=h(i);return[g(t*o,e*a),asin(e&&r*o/e)]}}var ut=azimuthalRaw((function(n){return w(2/(1+n))}));ut.invert=azimuthalInvert((function(n){return 2*asin(n/2)}));function azimuthalEqualArea(){return projection(ut).scale(124.75).clipAngle(179.999)}var lt=azimuthalRaw((function(n){return(n=acos(n))&&n/y(n)}));lt.invert=azimuthalInvert((function(n){return n}));function azimuthalEquidistant(){return projection(lt).scale(79.4188).clipAngle(179.999)}function mercatorRaw(n,t){return[n,E(P((a+t)/2))]}mercatorRaw.invert=function(n,t){return[n,2*p(v(t))-a]};function mercator(){return mercatorProjection(mercatorRaw).scale(961/u)}function mercatorProjection(n){var t,r,e,i=projection(n),a=i.center,c=i.scale,u=i.translate,l=i.clipExtent,s=null;i.scale=function(n){return arguments.length?(c(n),reclip()):c()};i.translate=function(n){return arguments.length?(u(n),reclip()):u()};i.center=function(n){return arguments.length?(a(n),reclip()):a()};i.clipExtent=function(n){return arguments.length?(n==null?s=t=r=e=null:(s=+n[0][0],t=+n[0][1],r=+n[1][0],e=+n[1][1]),reclip()):s==null?null:[[s,t],[r,e]]};function reclip(){var a=o*c(),u=i(rotation(i.rotate()).invert([0,0]));return l(s==null?[[u[0]-a,u[1]-a],[u[0]+a,u[1]+a]]:n===mercatorRaw?[[Math.max(u[0]-a,s),t],[Math.min(u[0]+a,r),e]]:[[s,Math.max(u[1]-a,t)],[r,Math.min(u[1]+a,e)]])}return reclip()}function tany(n){return P((a+n)/2)}function conicConformalRaw(n,t){var r=h(n),i=n===t?y(n):E(r/h(t))/E(tany(t)/tany(n)),c=r*S(tany(n),i)/i;if(!i)return mercatorRaw;function project(n,t){c>0?t<-a+e&&(t=-a+e):t>a-e&&(t=a-e);var r=c/S(tany(t),i);return[r*y(i*n),c-r*h(i*n)]}project.invert=function(n,t){var r=c-t,e=R(i)*w(n*n+r*r),u=g(n,f(r))*R(r);r*i<0&&(u-=o*R(n)*R(r));return[u/i,2*p(S(c/e,1/i))-a]};return project}function conicConformal(){return conicProjection(conicConformalRaw).scale(109.5).parallels([30,30])}function equirectangularRaw(n,t){return[n,t]}equirectangularRaw.invert=equirectangularRaw;function equirectangular(){return projection(equirectangularRaw).scale(152.63)}function conicEquidistantRaw(n,t){var r=h(n),i=n===t?y(n):(r-h(t))/(t-n),a=r/i+n;if(f(i)e&&--o>0);return[n/(.8707+(a=i*i)*(a*(a*a*a*(.003971-.001529*a)-.013791)-.131979)),i]};function naturalEarth1(){return projection(naturalEarth1Raw).scale(175.295)}function orthographicRaw(n,t){return[h(t)*y(n),y(t)]}orthographicRaw.invert=azimuthalInvert(asin);function orthographic(){return projection(orthographicRaw).scale(249.5).clipAngle(90+e)}function stereographicRaw(n,t){var r=h(t),e=1+h(n)*r;return[r*y(n)/e,y(t)/e]}stereographicRaw.invert=azimuthalInvert((function(n){return 2*p(n)}));function stereographic(){return projection(stereographicRaw).scale(250).clipAngle(142)}function transverseMercatorRaw(n,t){return[E(P((a+t)/2)),-n]}transverseMercatorRaw.invert=function(n,t){return[-t,2*p(v(n))-a]};function transverseMercator(){var n=mercatorProjection(transverseMercatorRaw),t=n.center,r=n.rotate;n.center=function(n){return arguments.length?t([-n[1],n[0]]):(n=t(),[n[1],-n[0]])};n.rotate=function(n){return arguments.length?r([n[0],n[1],n.length>2?n[2]+90:90]):(n=r(),[n[0],n[1],n[2]-90])};return r([0,0,90]).scale(159.155)}export{albers as geoAlbers,albersUsa as geoAlbersUsa,area as geoArea,azimuthalEqualArea as geoAzimuthalEqualArea,ut as geoAzimuthalEqualAreaRaw,azimuthalEquidistant as geoAzimuthalEquidistant,lt as geoAzimuthalEquidistantRaw,bounds as geoBounds,centroid as geoCentroid,circle as geoCircle,sn as geoClipAntimeridian,clipCircle as geoClipCircle,extent as geoClipExtent,clipRectangle as geoClipRectangle,conicConformal as geoConicConformal,conicConformalRaw as geoConicConformalRaw,conicEqualArea as geoConicEqualArea,conicEqualAreaRaw as geoConicEqualAreaRaw,conicEquidistant as geoConicEquidistant,conicEquidistantRaw as geoConicEquidistantRaw,contains as geoContains,distance as geoDistance,equalEarth as geoEqualEarth,equalEarthRaw as geoEqualEarthRaw,equirectangular as geoEquirectangular,equirectangularRaw as geoEquirectangularRaw,gnomonic as geoGnomonic,gnomonicRaw as geoGnomonicRaw,graticule as geoGraticule,graticule10 as geoGraticule10,identity as geoIdentity,interpolate as geoInterpolate,length as geoLength,mercator as geoMercator,mercatorRaw as geoMercatorRaw,naturalEarth1 as geoNaturalEarth1,naturalEarth1Raw as geoNaturalEarth1Raw,orthographic as geoOrthographic,orthographicRaw as geoOrthographicRaw,index as geoPath,projection as geoProjection,projectionMutator as geoProjectionMutator,rotation as geoRotation,stereographic as geoStereographic,stereographicRaw as geoStereographicRaw,geoStream,transform as geoTransform,transverseMercator as geoTransverseMercator,transverseMercatorRaw as geoTransverseMercatorRaw}; diff --git a/vendor/javascript/d3-hierarchy.js b/vendor/javascript/d3-hierarchy.js index 1d69d374..ab7f2f9d 100644 --- a/vendor/javascript/d3-hierarchy.js +++ b/vendor/javascript/d3-hierarchy.js @@ -1,2 +1,4 @@ +// d3-hierarchy@3.1.2 downloaded from https://ga.jspm.io/npm:d3-hierarchy@3.1.2/src/index.js + function defaultSeparation$1(e,n){return e.parent===n.parent?1:2}function meanX(e){return e.reduce(meanXReduce,0)/e.length}function meanXReduce(e,n){return e+n.x}function maxY(e){return 1+e.reduce(maxYReduce,0)}function maxYReduce(e,n){return Math.max(e,n.y)}function leafLeft(e){var n;while(n=e.children)e=n[0];return e}function leafRight(e){var n;while(n=e.children)e=n[n.length-1];return e}function cluster(){var e=defaultSeparation$1,n=1,t=1,r=false;function cluster(i){var a,o=0;i.eachAfter((function(n){var t=n.children;if(t){n.x=meanX(t);n.y=maxY(t)}else{n.x=a?o+=e(n,a):0;n.y=0;a=n}}));var u=leafLeft(i),c=leafRight(i),l=u.x-e(u,c)/2,s=c.x+e(c,u)/2;return i.eachAfter(r?function(e){e.x=(e.x-i.x)*n;e.y=(i.y-e.y)*t}:function(e){e.x=(e.x-l)/(s-l)*n;e.y=(1-(i.y?e.y/i.y:1))*t})}cluster.separation=function(n){return arguments.length?(e=n,cluster):e};cluster.size=function(e){return arguments.length?(r=false,n=+e[0],t=+e[1],cluster):r?null:[n,t]};cluster.nodeSize=function(e){return arguments.length?(r=true,n=+e[0],t=+e[1],cluster):r?[n,t]:null};return cluster}function count(e){var n=0,t=e.children,r=t&&t.length;if(r)while(--r>=0)n+=t[r].value;else n=1;e.value=n}function node_count(){return this.eachAfter(count)}function node_each(e,n){let t=-1;for(const r of this)e.call(n,r,++t,this);return this}function node_eachBefore(e,n){var t,r,i=this,a=[i],o=-1;while(i=a.pop()){e.call(n,i,++o,this);if(t=i.children)for(r=t.length-1;r>=0;--r)a.push(t[r])}return this}function node_eachAfter(e,n){var t,r,i,a=this,o=[a],u=[],c=-1;while(a=o.pop()){u.push(a);if(t=a.children)for(r=0,i=t.length;r=0)t+=r[i].value;n.value=t}))}function node_sort(e){return this.eachBefore((function(n){n.children&&n.children.sort(e)}))}function node_path(e){var n=this,t=leastCommonAncestor(n,e),r=[n];while(n!==t){n=n.parent;r.push(n)}var i=r.length;while(e!==t){r.splice(i,0,e);e=e.parent}return r}function leastCommonAncestor(e,n){if(e===n)return e;var t=e.ancestors(),r=n.ancestors(),i=null;e=t.pop();n=r.pop();while(e===n){i=e;e=t.pop();n=r.pop()}return i}function node_ancestors(){var e=this,n=[e];while(e=e.parent)n.push(e);return n}function node_descendants(){return Array.from(this)}function node_leaves(){var e=[];this.eachBefore((function(n){n.children||e.push(n)}));return e}function node_links(){var e=this,n=[];e.each((function(t){t!==e&&n.push({source:t.parent,target:t})}));return n}function*node_iterator(){var e,n,t,r,i=this,a=[i];do{e=a.reverse(),a=[];while(i=e.pop()){yield i;if(n=i.children)for(t=0,r=n.length;t=0;--a){c.push(r=i[a]=new Node$1(i[a]));r.parent=t;r.depth=t.depth+1}}return u.eachBefore(computeHeight)}function node_copy(){return hierarchy(this).eachBefore(copyData)}function objectChildren(e){return e.children}function mapChildren(e){return Array.isArray(e)?e[1]:null}function copyData(e){void 0!==e.data.value&&(e.value=e.data.value);e.data=e.data.data}function computeHeight(e){var n=0;do{e.height=n}while((e=e.parent)&&e.height<++n)}function Node$1(e){this.data=e;this.depth=this.height=0;this.parent=null}Node$1.prototype=hierarchy.prototype={constructor:Node$1,count:node_count,each:node_each,eachAfter:node_eachAfter,eachBefore:node_eachBefore,find:node_find,sum:node_sum,sort:node_sort,path:node_path,ancestors:node_ancestors,descendants:node_descendants,leaves:node_leaves,links:node_links,copy:node_copy,[Symbol.iterator]:node_iterator};function optional(e){return null==e?null:required(e)}function required(e){if("function"!==typeof e)throw new Error;return e}function constantZero(){return 0}function constant(e){return function(){return e}}const e=1664525;const n=1013904223;const t=4294967296;function lcg(){let r=1;return()=>(r=(e*r+n)%t)/t}function array(e){return"object"===typeof e&&"length"in e?e:Array.from(e)}function shuffle(e,n){let t,r,i=e.length;while(i){r=n()*i--|0;t=e[i];e[i]=e[r];e[r]=t}return e}function enclose(e){return packEncloseRandom(e,lcg())}function packEncloseRandom(e,n){var t,r,i=0,a=(e=shuffle(Array.from(e),n)).length,o=[];while(i0&&t*t>r*r+i*i}function enclosesWeakAll(e,n){for(var t=0;t1e-6?(z+Math.sqrt(z*z-4*R*M))/(2*R):M/z);return{x:r+B+k*S,y:i+N+A*S,r:S}}function place(e,n,t){var r,i,a,o,u=e.x-n.x,c=e.y-n.y,l=u*u+c*c;if(l){i=n.r+t.r,i*=i;o=e.r+t.r,o*=o;if(i>o){r=(l+o-i)/(2*l);a=Math.sqrt(Math.max(0,o/l-r*r));t.x=e.x-r*u-a*c;t.y=e.y-r*c+a*u}else{r=(l+i-o)/(2*l);a=Math.sqrt(Math.max(0,i/l-r*r));t.x=n.x+r*u-a*c;t.y=n.y+r*c+a*u}}else{t.x=n.x+t.r;t.y=n.y}}function intersects(e,n){var t=e.r+n.r-1e-6,r=n.x-e.x,i=n.y-e.y;return t>0&&t*t>r*r+i*i}function score(e){var n=e._,t=e.next._,r=n.r+t.r,i=(n.x*t.r+t.x*n.r)/r,a=(n.y*t.r+t.y*n.r)/r;return i*i+a*a}function Node(e){this._=e;this.next=null;this.previous=null}function packSiblingsRandom(e,n){if(!(a=(e=array(e)).length))return 0;var t,r,i,a,o,u,c,l,s,f,h;t=e[0],t.x=0,t.y=0;if(!(a>1))return t.r;r=e[1],t.x=-r.r,r.x=t.r,r.y=0;if(!(a>2))return t.r+r.r;place(r,t,i=e[2]);t=new Node(t),r=new Node(r),i=new Node(i);t.next=i.previous=r;r.next=t.previous=i;i.next=r.previous=t;e:for(c=3;cnormalize(e(n,t,o))));const t=n.map(parentof);const r=new Set(n).add("");for(const e of t)if(!r.has(e)){r.add(e);n.push(e);t.push(parentof(e));y.push(a)}x=(e,t)=>n[t];m=(e,n)=>t[n]}for(l=0,u=y.length;l=0;--e){h=y[e];if(h.data!==a)break;h.data=null}}s.parent=r;s.eachBefore((function(e){e.depth=e.parent.depth+1;--u})).eachBefore(computeHeight);s.parent=null;if(u>0)throw new Error("cycle");return s}stratify.id=function(e){return arguments.length?(n=optional(e),stratify):n};stratify.parentId=function(e){return arguments.length?(t=optional(e),stratify):t};stratify.path=function(n){return arguments.length?(e=optional(n),stratify):e};return stratify}function normalize(e){e=`${e}`;let n=e.length;slash(e,n-1)&&!slash(e,n-2)&&(e=e.slice(0,-1));return"/"===e[0]?e:`/${e}`}function parentof(e){let n=e.length;if(n<2)return"";while(--n>1)if(slash(e,n))break;return e.slice(0,n)}function slash(e,n){if("/"===e[n]){let t=0;while(n>0&&"\\"===e[--n])++t;if(0===(1&t))return true}return false}function defaultSeparation(e,n){return e.parent===n.parent?1:2}function nextLeft(e){var n=e.children;return n?n[0]:e.t}function nextRight(e){var n=e.children;return n?n[n.length-1]:e.t}function moveSubtree(e,n,t){var r=t/(n.i-e.i);n.c-=r;n.s+=t;e.c+=r;n.z+=t;n.m+=t}function executeShifts(e){var n,t=0,r=0,i=e.children,a=i.length;while(--a>=0){n=i[a];n.z+=t;n.m+=t;t+=n.s+(r+=n.c)}}function nextAncestor(e,n,t){return e.a.parent===n.parent?e.a:t}function TreeNode(e,n){this._=e;this.parent=null;this.children=null;this.A=null;this.a=this;this.z=0;this.m=0;this.c=0;this.s=0;this.t=null;this.i=n}TreeNode.prototype=Object.create(Node$1.prototype);function treeRoot(e){var n,t,r,i,a,o=new TreeNode(e,0),u=[o];while(n=u.pop())if(r=n._.children){n.children=new Array(a=r.length);for(i=a-1;i>=0;--i){u.push(t=n.children[i]=new TreeNode(r[i],i));t.parent=n}}(o.parent=new TreeNode(null,0)).children=[o];return o}function tree(){var e=defaultSeparation,n=1,t=1,r=null;function tree(i){var a=treeRoot(i);a.eachAfter(firstWalk),a.parent.m=-a.z;a.eachBefore(secondWalk);if(r)i.eachBefore(sizeNode);else{var o=i,u=i,c=i;i.eachBefore((function(e){e.xu.x&&(u=e);e.depth>c.depth&&(c=e)}));var l=o===u?1:e(o,u)/2,s=l-o.x,f=n/(u.x+l+s),h=t/(c.depth||1);i.eachBefore((function(e){e.x=(e.x+s)*f;e.y=e.depth*h}))}return i}function firstWalk(n){var t=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(t){executeShifts(n);var a=(t[0].z+t[t.length-1].z)/2;if(i){n.z=i.z+e(n._,i._);n.m=n.z-a}else n.z=a}else i&&(n.z=i.z+e(n._,i._));n.parent.A=apportion(n,i,n.parent.A||r[0])}function secondWalk(e){e._.x=e.z+e.parent.m;e.m+=e.parent.m}function apportion(n,t,r){if(t){var i,a=n,o=n,u=t,c=a.parent.children[0],l=a.m,s=o.m,f=u.m,h=c.m;while(u=nextRight(u),a=nextLeft(a),u&&a){c=nextLeft(c);o=nextRight(o);o.a=n;i=u.z+f-a.z-l+e(u._,a._);if(i>0){moveSubtree(nextAncestor(u,n,r),n,i);l+=i;s+=i}f+=u.m;l+=a.m;h+=c.m;s+=o.m}if(u&&!nextRight(o)){o.t=u;o.m+=f-s}if(a&&!nextLeft(c)){c.t=a;c.m+=l-h;r=n}}return r}function sizeNode(e){e.x*=n;e.y=e.depth*t}tree.separation=function(n){return arguments.length?(e=n,tree):e};tree.size=function(e){return arguments.length?(r=false,n=+e[0],t=+e[1],tree):r?null:[n,t]};tree.nodeSize=function(e){return arguments.length?(r=true,n=+e[0],t=+e[1],tree):r?[n,t]:null};return tree}function treemapSlice(e,n,t,r,i){var a,o=e.children,u=-1,c=o.length,l=e.value&&(i-t)/e.value;while(++uh&&(h=u);x=s*s*y;d=Math.max(h/x,x/f);if(d>p){s-=u;break}p=d}m.push(o={value:s,dice:c1?e:1)};return squarify}(o);function index(){var e=u,n=false,t=1,r=1,i=[0],a=constantZero,o=constantZero,c=constantZero,l=constantZero,s=constantZero;function treemap(e){e.x0=e.y0=0;e.x1=t;e.y1=r;e.eachBefore(positionNode);i=[0];n&&e.eachBefore(roundNode);return e}function positionNode(n){var t=i[n.depth],r=n.x0+t,u=n.y0+t,f=n.x1-t,h=n.y1-t;f=n-1){var c=u[e];c.x0=r,c.y0=i;c.x1=a,c.y1=o}else{var s=l[e],f=t/2+s,h=e+1,d=n-1;while(h>>1;l[p]o-i){var m=t?(r*x+a*y)/t:a;partition(e,h,y,r,i,m,o);partition(h,n,x,m,i,a,o)}else{var v=t?(i*x+o*y)/t:o;partition(e,h,y,r,i,a,v);partition(h,n,x,r,v,a,o)}}}}function sliceDice(e,n,t,r,i){(1&e.depth?treemapSlice:treemapDice)(e,n,t,r,i)}var c=function custom(e){function resquarify(n,t,r,i,a){if((o=n._squarify)&&o.ratio===e){var o,u,c,l,s,f=-1,h=o.length,d=n.value;while(++f1?e:1)};return resquarify}(o);export{Node$1 as Node,cluster,hierarchy,index$1 as pack,enclose as packEnclose,siblings as packSiblings,partition,stratify,tree,index as treemap,binary as treemapBinary,treemapDice,c as treemapResquarify,treemapSlice,sliceDice as treemapSliceDice,u as treemapSquarify}; diff --git a/vendor/javascript/d3-interpolate.js b/vendor/javascript/d3-interpolate.js index 9822fa1f..626ad6fd 100644 --- a/vendor/javascript/d3-interpolate.js +++ b/vendor/javascript/d3-interpolate.js @@ -1,2 +1,4 @@ +// d3-interpolate@3.0.1 downloaded from https://ga.jspm.io/npm:d3-interpolate@3.0.1/src/index.js + import{rgb as n,color as r,hsl as t,lab as e,hcl as a,cubehelix as o}from"d3-color";function basis(n,r,t,e,a){var o=n*n,u=o*n;return((1-3*n+3*o-u)*r+(4-6*o+3*u)*t+(1+3*n+3*o-3*u)*e+u*a)/6}function basis$1(n){var r=n.length-1;return function(t){var e=t<=0?t=0:t>=1?(t=1,r-1):Math.floor(t*r),a=n[e],o=n[e+1],u=e>0?n[e-1]:2*a-o,i=e()=>n;function linear(n,r){return function(t){return n+t*r}}function exponential(n,r,t){return n=Math.pow(n,t),r=Math.pow(r,t)-n,t=1/t,function(e){return Math.pow(n+e*r,t)}}function hue$1(n,r){var t=r-n;return t?linear(n,t>180||t<-180?t-360*Math.round(t/360):t):constant(isNaN(n)?r:n)}function gamma(n){return 1===(n=+n)?nogamma:function(r,t){return t-r?exponential(r,t,n):constant(isNaN(r)?t:r)}}function nogamma(n,r){var t=r-n;return t?linear(n,t):constant(isNaN(n)?r:n)}var u=function rgbGamma(r){var t=gamma(r);function rgb(r,e){var a=t((r=n(r)).r,(e=n(e)).r),o=t(r.g,e.g),u=t(r.b,e.b),i=nogamma(r.opacity,e.opacity);return function(n){r.r=a(n);r.g=o(n);r.b=u(n);r.opacity=i(n);return r+""}}rgb.gamma=rgbGamma;return rgb}(1);function rgbSpline(r){return function(t){var e,a,o=t.length,u=new Array(o),i=new Array(o),s=new Array(o);for(e=0;eo){a=r.slice(o,a);i[u]?i[u]+=a:i[++u]=a}if((t=t[0])===(e=e[0]))i[u]?i[u]+=e:i[++u]=e;else{i[++u]=null;s.push({i:u,x:number(t,e)})}o=c.lastIndex}if(o180?r+=360:r-n>180&&(n+=360);a.push({i:t.push(pop(t)+"rotate(",null,e)-2,x:number(n,r)})}else r&&t.push(pop(t)+"rotate("+r+e)}function skewX(n,r,t,a){n!==r?a.push({i:t.push(pop(t)+"skewX(",null,e)-2,x:number(n,r)}):r&&t.push(pop(t)+"skewX("+r+e)}function scale(n,r,t,e,a,o){if(n!==t||r!==e){var u=a.push(pop(a)+"scale(",null,",",null,")");o.push({i:u-4,x:number(n,t)},{i:u-2,x:number(r,e)})}else 1===t&&1===e||a.push(pop(a)+"scale("+t+","+e+")")}return function(r,t){var e=[],a=[];r=n(r),t=n(t);translate(r.translateX,r.translateY,t.translateX,t.translateY,e,a);rotate(r.rotate,t.rotate,e,a);skewX(r.skewX,t.skewX,e,a);scale(r.scaleX,r.scaleY,t.scaleX,t.scaleY,e,a);r=t=null;return function(n){var r,t=-1,o=a.length;while(++t=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;hi)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}; diff --git a/vendor/javascript/d3-polygon.js b/vendor/javascript/d3-polygon.js index 4ec6cc5c..f9371c51 100644 --- a/vendor/javascript/d3-polygon.js +++ b/vendor/javascript/d3-polygon.js @@ -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(++e1&&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=0;--r)i.push(n[t[l[r]][2]]);for(r=+a;ra!==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=(h=(x+v)/2))?x=h:v=h;(l=i>=(s=(c+w)/2))?c=s:w=s;if(n=f,!(f=f[_=l<<1|u]))return n[_]=y,t}a=+t._x.call(null,f.data);o=+t._y.call(null,f.data);if(e===a&&i===o)return y.next=f,n?n[_]=y:t._root=y,t;do{n=n?n[_]=new Array(4):t._root=new Array(4);(u=e>=(h=(x+v)/2))?x=h:v=h;(l=i>=(s=(c+w)/2))?c=s:w=s}while((_=l<<1|u)===(d=(o>=s)<<1|a>=h));return n[d]=f,n[_]=y,t}function addAll(t){var e,i,r,n,h=t.length,s=new Array(h),a=new Array(h),o=Infinity,u=Infinity,l=-Infinity,_=-Infinity;for(i=0;il&&(l=r);n_&&(_=n)}if(o>l||u>_)return this;this.cover(o,u).cover(l,_);for(i=0;it||t>=n||r>e||e>=h){a=(ed||(h=o.y0)>f||(s=o.x1)=v)<<1|t>=c){o=y[y.length-1];y[y.length-1]=y[y.length-1-u];y[y.length-1-u]=o}}else{var w=t-+this._x.call(null,x.data),p=e-+this._y.call(null,x.data),N=w*w+p*p;if(N=(a=(y+c)/2))?y=a:c=a;(l=s>=(o=(x+v)/2))?x=o:v=o;if(!(e=f,f=f[_=l<<1|u]))return this;if(!f.length)break;(e[_+1&3]||e[_+2&3]||e[_+3&3])&&(i=e,d=_)}while(f.data!==t)if(!(r=f,f=f.next))return this;(n=f.next)&&delete f.next;if(r)return n?r.next=n:delete r.next,this;if(!e)return this._root=n,this;n?e[_]=n:delete e[_];(f=e[0]||e[1]||e[2]||e[3])&&f===(e[3]||e[2]||e[1]||e[0])&&!f.length&&(i?i[d]=f:this._root=f);return this}function removeAll(t){for(var e=0,i=t.length;e1);return n+o*t*Math.sqrt(-2*Math.log(u)/u)}}randomNormal.source=sourceRandomNormal;return randomNormal}(r);var u=function sourceRandomLogNormal(r){var n=a.source(r);function randomLogNormal(){var r=n.apply(this,arguments);return function(){return Math.exp(r())}}randomLogNormal.source=sourceRandomLogNormal;return randomLogNormal}(r);var t=function sourceRandomIrwinHall(r){function randomIrwinHall(n){return(n=+n)<=0?()=>0:function(){for(var o=0,a=n;a>1;--a)o+=r();return o+a*r()}}randomIrwinHall.source=sourceRandomIrwinHall;return randomIrwinHall}(r);var e=function sourceRandomBates(r){var n=t.source(r);function randomBates(o){if(0===(o=+o))return r;var a=n(o);return function(){return a()/o}}randomBates.source=sourceRandomBates;return randomBates}(r);var i=function sourceRandomExponential(r){function randomExponential(n){return function(){return-Math.log1p(-r())/n}}randomExponential.source=sourceRandomExponential;return randomExponential}(r);var m=function sourceRandomPareto(r){function randomPareto(n){if((n=+n)<0)throw new RangeError("invalid alpha");n=1/-n;return function(){return Math.pow(1-r(),n)}}randomPareto.source=sourceRandomPareto;return randomPareto}(r);var c=function sourceRandomBernoulli(r){function randomBernoulli(n){if((n=+n)<0||n>1)throw new RangeError("invalid p");return function(){return Math.floor(r()+n)}}randomBernoulli.source=sourceRandomBernoulli;return randomBernoulli}(r);var l=function sourceRandomGeometric(r){function randomGeometric(n){if((n=+n)<0||n>1)throw new RangeError("invalid p");if(0===n)return()=>Infinity;if(1===n)return()=>1;n=Math.log1p(-n);return function(){return 1+Math.floor(Math.log1p(-r())/n)}}randomGeometric.source=sourceRandomGeometric;return randomGeometric}(r);var d=function sourceRandomGamma(r){var n=a.source(r)();function randomGamma(o,a){if((o=+o)<0)throw new RangeError("invalid k");if(0===o)return()=>0;a=null==a?1:+a;if(1===o)return()=>-Math.log1p(-r())*a;var u=(o<1?o+1:o)-1/3,t=1/(3*Math.sqrt(u)),e=o<1?()=>Math.pow(r(),1/o):()=>1;return function(){do{do{var o=n(),i=1+t*o}while(i<=0);i*=i*i;var m=1-r()}while(m>=1-.0331*o*o*o*o&&Math.log(m)>=.5*o*o+u*(1-i+Math.log(i)));return u*i*e()*a}}randomGamma.source=sourceRandomGamma;return randomGamma}(r);var s=function sourceRandomBeta(r){var n=d.source(r);function randomBeta(r,o){var a=n(r),u=n(o);return function(){var r=a();return 0===r?0:r/(r+u())}}randomBeta.source=sourceRandomBeta;return randomBeta}(r);var f=function sourceRandomBinomial(r){var n=l.source(r),o=s.source(r);function randomBinomial(r,a){r=+r;return(a=+a)>=1?()=>r:a<=0?()=>0:function(){var u=0,t=r,e=a;while(t*e>16&&t*(1-e)>16){var i=Math.floor((t+1)*e),m=o(i,t-i+1)();if(m<=e){u+=i;t-=i;e=(e-m)/(1-m)}else{t=i-1;e/=m}}var c=e<.5,l=c?e:1-e,d=n(l);for(var s=d(),f=0;s<=t;++f)s+=d();return u+(c?f:t-f)}}randomBinomial.source=sourceRandomBinomial;return randomBinomial}(r);var h=function sourceRandomWeibull(r){function randomWeibull(n,o,a){var u;if(0===(n=+n))u=r=>-Math.log(r);else{n=1/n;u=r=>Math.pow(r,n)}o=null==o?0:+o;a=null==a?1:+a;return function(){return o+a*u(-Math.log1p(-r()))}}randomWeibull.source=sourceRandomWeibull;return randomWeibull}(r);var v=function sourceRandomCauchy(r){function randomCauchy(n,o){n=null==n?0:+n;o=null==o?1:+o;return function(){return n+o*Math.tan(Math.PI*r())}}randomCauchy.source=sourceRandomCauchy;return randomCauchy}(r);var R=function sourceRandomLogistic(r){function randomLogistic(n,o){n=null==n?0:+n;o=null==o?1:+o;return function(){var a=r();return n+o*Math.log(a/(1-a))}}randomLogistic.source=sourceRandomLogistic;return randomLogistic}(r);var g=function sourceRandomPoisson(r){var n=d.source(r),o=f.source(r);function randomPoisson(a){return function(){var u=0,t=a;while(t>16){var e=Math.floor(.875*t),i=n(e)();if(i>t)return u+o(e-1,t/i)();u+=e;t-=i}for(var m=-Math.log1p(-r()),c=0;m<=t;++c)m-=Math.log1p(-r());return u+c}}randomPoisson.source=sourceRandomPoisson;return randomPoisson}(r);const M=1664525;const B=1013904223;const p=1/4294967296;function lcg(r=Math.random()){let n=0|(0<=r&&r<1?r/p:Math.abs(r));return()=>(n=M*n+B|0,p*(n>>>0))}export{e as randomBates,c as randomBernoulli,s as randomBeta,f as randomBinomial,v as randomCauchy,i as randomExponential,d as randomGamma,l as randomGeometric,o as randomInt,t as randomIrwinHall,lcg as randomLcg,u as randomLogNormal,R as randomLogistic,a as randomNormal,m as randomPareto,g as randomPoisson,n as randomUniform,h as randomWeibull}; diff --git a/vendor/javascript/d3-sankey.js b/vendor/javascript/d3-sankey.js new file mode 100644 index 00000000..704a8471 --- /dev/null +++ b/vendor/javascript/d3-sankey.js @@ -0,0 +1,4 @@ +// d3-sankey@0.12.3 downloaded from https://ga.jspm.io/npm:d3-sankey@0.12.3/dist/d3-sankey.js + +import e from"d3-array";import t from"d3-shape";var n={};(function(o,r){r(n,e,t)})(n,(function(e,t,n){function targetDepth(e){return e.target.depth}function left(e){return e.depth}function right(e,t){return t-1-e.height}function justify(e,t){return e.sourceLinks.length?e.depth:t-1}function center(e){return e.targetLinks.length?e.depth:e.sourceLinks.length?t.min(e.sourceLinks,targetDepth)-1:0}function constant(e){return function(){return e}}function ascendingSourceBreadth(e,t){return ascendingBreadth(e.source,t.source)||e.index-t.index}function ascendingTargetBreadth(e,t){return ascendingBreadth(e.target,t.target)||e.index-t.index}function ascendingBreadth(e,t){return e.y0-t.y0}function value(e){return e.value}function defaultId(e){return e.index}function defaultNodes(e){return e.nodes}function defaultLinks(e){return e.links}function find(e,t){const n=e.get(t);if(!n)throw new Error("missing: "+t);return n}function computeLinkBreadths({nodes:e}){for(const t of e){let e=t.y0;let n=e;for(const n of t.sourceLinks){n.y0=e+n.width/2;e+=n.width}for(const e of t.targetLinks){e.y1=n+e.width/2;n+=e.width}}}function Sankey(){let e=0,n=0,o=1,r=1;let s=24;let i=8,a;let u=defaultId;let c=justify;let f;let l;let d=defaultNodes;let k=defaultLinks;let h=6;function sankey(){const e={nodes:d.apply(null,arguments),links:k.apply(null,arguments)};computeNodeLinks(e);computeNodeValues(e);computeNodeDepths(e);computeNodeHeights(e);computeNodeBreadths(e);computeLinkBreadths(e);return e}sankey.update=function(e){computeLinkBreadths(e);return e};sankey.nodeId=function(e){return arguments.length?(u="function"===typeof e?e:constant(e),sankey):u};sankey.nodeAlign=function(e){return arguments.length?(c="function"===typeof e?e:constant(e),sankey):c};sankey.nodeSort=function(e){return arguments.length?(f=e,sankey):f};sankey.nodeWidth=function(e){return arguments.length?(s=+e,sankey):s};sankey.nodePadding=function(e){return arguments.length?(i=a=+e,sankey):i};sankey.nodes=function(e){return arguments.length?(d="function"===typeof e?e:constant(e),sankey):d};sankey.links=function(e){return arguments.length?(k="function"===typeof e?e:constant(e),sankey):k};sankey.linkSort=function(e){return arguments.length?(l=e,sankey):l};sankey.size=function(t){return arguments.length?(e=n=0,o=+t[0],r=+t[1],sankey):[o-e,r-n]};sankey.extent=function(t){return arguments.length?(e=+t[0][0],o=+t[1][0],n=+t[0][1],r=+t[1][1],sankey):[[e,n],[o,r]]};sankey.iterations=function(e){return arguments.length?(h=+e,sankey):h};function computeNodeLinks({nodes:e,links:t}){for(const[t,n]of e.entries()){n.index=t;n.sourceLinks=[];n.targetLinks=[]}const n=new Map(e.map((t,n)=>[u(t,n,e),t]));for(const[e,o]of t.entries()){o.index=e;let{source:t,target:r}=o;"object"!==typeof t&&(t=o.source=find(n,t));"object"!==typeof r&&(r=o.target=find(n,r));t.sourceLinks.push(o);r.targetLinks.push(o)}if(null!=l)for(const{sourceLinks:t,targetLinks:n}of e){t.sort(l);n.sort(l)}}function computeNodeValues({nodes:e}){for(const n of e)n.value=void 0===n.fixedValue?Math.max(t.sum(n.sourceLinks,value),t.sum(n.targetLinks,value)):n.fixedValue}function computeNodeDepths({nodes:e}){const t=e.length;let n=new Set(e);let o=new Set;let r=0;while(n.size){for(const e of n){e.depth=r;for(const{target:t}of e.sourceLinks)o.add(t)}if(++r>t)throw new Error("circular link");n=o;o=new Set}}function computeNodeHeights({nodes:e}){const t=e.length;let n=new Set(e);let o=new Set;let r=0;while(n.size){for(const e of n){e.height=r;for(const{source:t}of e.targetLinks)o.add(t)}if(++r>t)throw new Error("circular link");n=o;o=new Set}}function computeNodeLayers({nodes:n}){const r=t.max(n,e=>e.depth)+1;const i=(o-e-s)/(r-1);const a=new Array(r);for(const t of n){const n=Math.max(0,Math.min(r-1,Math.floor(c.call(null,t,r))));t.layer=n;t.x0=e+n*i;t.x1=t.x0+s;a[n]?a[n].push(t):a[n]=[t]}if(f)for(const e of a)e.sort(f);return a}function initializeNodeBreadths(e){const o=t.min(e,e=>(r-n-(e.length-1)*a)/t.sum(e,value));for(const t of e){let e=n;for(const n of t){n.y0=e;n.y1=e+n.value*o;e=n.y1+a;for(const e of n.sourceLinks)e.width=e.value*o}e=(r-e+a)/(t.length+1);for(let n=0;ne.length)-1));initializeNodeBreadths(o);for(let e=0;e0))continue;let r=(n/o-e.y0)*t;e.y0+=r;e.y1+=r;reorderNodeLinks(e)}void 0===f&&r.sort(ascendingBreadth);resolveCollisions(r,n)}}function relaxRightToLeft(e,t,n){for(let o=e.length,r=o-2;r>=0;--r){const o=e[r];for(const e of o){let n=0;let o=0;for(const{target:t,value:r}of e.sourceLinks){let s=r*(t.layer-e.layer);n+=sourceTop(e,t)*s;o+=s}if(!(o>0))continue;let r=(n/o-e.y0)*t;e.y0+=r;e.y1+=r;reorderNodeLinks(e)}void 0===f&&o.sort(ascendingBreadth);resolveCollisions(o,n)}}function resolveCollisions(e,t){const o=e.length>>1;const s=e[o];resolveCollisionsBottomToTop(e,s.y0-a,o-1,t);resolveCollisionsTopToBottom(e,s.y1+a,o+1,t);resolveCollisionsBottomToTop(e,r,e.length-1,t);resolveCollisionsTopToBottom(e,n,0,t)}function resolveCollisionsTopToBottom(e,t,n,o){for(;n1e-6&&(r.y0+=s,r.y1+=s);t=r.y1+a}}function resolveCollisionsBottomToTop(e,t,n,o){for(;n>=0;--n){const r=e[n];const s=(r.y1-t)*o;s>1e-6&&(r.y0-=s,r.y1-=s);t=r.y0-a}}function reorderNodeLinks({sourceLinks:e,targetLinks:t}){if(void 0===l){for(const{source:{sourceLinks:e}}of t)e.sort(ascendingTargetBreadth);for(const{target:{targetLinks:t}}of e)t.sort(ascendingSourceBreadth)}}function reorderLinks(e){if(void 0===l)for(const{sourceLinks:t,targetLinks:n}of e){t.sort(ascendingTargetBreadth);n.sort(ascendingSourceBreadth)}}function targetTop(e,t){let n=e.y0-(e.sourceLinks.length-1)*a/2;for(const{target:o,width:r}of e.sourceLinks){if(o===t)break;n+=r+a}for(const{source:o,width:r}of t.targetLinks){if(o===e)break;n-=r}return n}function sourceTop(e,t){let n=t.y0-(t.targetLinks.length-1)*a/2;for(const{source:o,width:r}of t.targetLinks){if(o===e)break;n+=r+a}for(const{target:o,width:r}of e.sourceLinks){if(o===t)break;n-=r}return n}return sankey}function horizontalSource(e){return[e.source.x1,e.y0]}function horizontalTarget(e){return[e.target.x0,e.y1]}function sankeyLinkHorizontal(){return n.linkHorizontal().source(horizontalSource).target(horizontalTarget)}e.sankey=Sankey;e.sankeyCenter=center;e.sankeyJustify=justify;e.sankeyLeft=left;e.sankeyLinkHorizontal=sankeyLinkHorizontal;e.sankeyRight=right;Object.defineProperty(e,"__esModule",{value:true})}));const o=n.sankey,r=n.sankeyCenter,s=n.sankeyJustify,i=n.sankeyLeft,a=n.sankeyLinkHorizontal,u=n.sankeyRight,c=n.__esModule;export default n;export{c as __esModule,o as sankey,r as sankeyCenter,s as sankeyJustify,i as sankeyLeft,a as sankeyLinkHorizontal,u as sankeyRight}; + diff --git a/vendor/javascript/d3-scale-chromatic.js b/vendor/javascript/d3-scale-chromatic.js index 6f6d4230..d3df76ab 100644 --- a/vendor/javascript/d3-scale-chromatic.js +++ b/vendor/javascript/d3-scale-chromatic.js @@ -1,2 +1,4 @@ -import{interpolateRgbBasis as f,interpolateCubehelixLong as e}from"d3-interpolate";import{cubehelix as a,rgb as d}from"d3-color";function colors(f){var e=f.length/6|0,a=new Array(e),d=0;while(df(e[e.length-1]);var v=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(colors);var p=ramp$1(v);var h=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(colors);var u=ramp$1(h);var w=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(colors);var M=ramp$1(w);var y=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(colors);var A=ramp$1(y);var P=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(colors);var B=ramp$1(P);var G=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(colors);var R=ramp$1(G);var Y=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(colors);var x=ramp$1(Y);var g=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(colors);var O=ramp$1(g);var S=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(colors);var C=ramp$1(S);var I=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(colors);var D=ramp$1(I);var T=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(colors);var k=ramp$1(T);var V=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(colors);var W=ramp$1(V);var j=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(colors);var q=ramp$1(j);var z=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(colors);var E=ramp$1(z);var F=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(colors);var H=ramp$1(F);var J=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(colors);var K=ramp$1(J);var L=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(colors);var N=ramp$1(L);var Q=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(colors);var U=ramp$1(Q);var X=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(colors);var Z=ramp$1(X);var $=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(colors);var _=ramp$1($);var ff=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(colors);var ef=ramp$1(ff);var af=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(colors);var df=ramp$1(af);var cf=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(colors);var bf=ramp$1(cf);var rf=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(colors);var of=ramp$1(rf);var sf=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(colors);var tf=ramp$1(sf);var nf=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(colors);var lf=ramp$1(nf);var mf=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(colors);var vf=ramp$1(mf);function cividis(f){f=Math.max(0,Math.min(1,f));return"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-f*(35.34-f*(2381.73-f*(6402.7-f*(7024.72-2710.57*f)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+f*(170.73+f*(52.82-f*(131.46-f*(176.58-67.37*f)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+f*(442.36-f*(2482.43-f*(6167.24-f*(6614.94-2475.67*f)))))))+")"}var pf=e(a(300,.5,0),a(-240,.5,1));var hf=e(a(-100,.75,.35),a(80,1.5,.8));var uf=e(a(260,.75,.35),a(80,1.5,.8));var wf=a();function rainbow(f){(f<0||f>1)&&(f-=Math.floor(f));var e=Math.abs(f-.5);wf.h=360*f-100;wf.s=1.5-1.5*e;wf.l=.8-.9*e;return wf+""}var Mf=d(),yf=Math.PI/3,Af=2*Math.PI/3;function sinebow(f){var e;f=(.5-f)*Math.PI;Mf.r=255*(e=Math.sin(f))*e;Mf.g=255*(e=Math.sin(f+yf))*e;Mf.b=255*(e=Math.sin(f+Af))*e;return Mf+""}function turbo(f){f=Math.max(0,Math.min(1,f));return"rgb("+Math.max(0,Math.min(255,Math.round(34.61+f*(1172.33-f*(10793.56-f*(33300.12-f*(38394.49-14825.05*f)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+f*(557.33+f*(1225.33-f*(3574.96-f*(1073.77+707.56*f)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+f*(3211.1-f*(15327.97-f*(27814-f*(22569.18-6838.66*f)))))))+")"}function ramp(f){var e=f.length;return function(a){return f[Math.max(0,Math.min(e-1,Math.floor(a*e)))]}}var Pf=ramp(colors("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725"));var Bf=ramp(colors("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf"));var Gf=ramp(colors("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4"));var Rf=ramp(colors("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));export{df as interpolateBlues,p as interpolateBrBG,D as interpolateBuGn,k as interpolateBuPu,cividis as interpolateCividis,uf as interpolateCool,pf as interpolateCubehelixDefault,W as interpolateGnBu,bf as interpolateGreens,of as interpolateGreys,Gf as interpolateInferno,Bf as interpolateMagma,q as interpolateOrRd,vf as interpolateOranges,u as interpolatePRGn,M as interpolatePiYG,Rf as interpolatePlasma,H as interpolatePuBu,E as interpolatePuBuGn,A as interpolatePuOr,K as interpolatePuRd,tf as interpolatePurples,rainbow as interpolateRainbow,B as interpolateRdBu,R as interpolateRdGy,N as interpolateRdPu,x as interpolateRdYlBu,O as interpolateRdYlGn,lf as interpolateReds,sinebow as interpolateSinebow,C as interpolateSpectral,turbo as interpolateTurbo,Pf as interpolateViridis,hf as interpolateWarm,Z as interpolateYlGn,U as interpolateYlGnBu,_ as interpolateYlOrBr,ef as interpolateYlOrRd,b as schemeAccent,af as schemeBlues,v as schemeBrBG,I as schemeBuGn,T as schemeBuPu,c as schemeCategory10,r as schemeDark2,V as schemeGnBu,cf as schemeGreens,rf as schemeGreys,j as schemeOrRd,mf as schemeOranges,h as schemePRGn,o as schemePaired,s as schemePastel1,t as schemePastel2,w as schemePiYG,F as schemePuBu,z as schemePuBuGn,y as schemePuOr,J as schemePuRd,sf as schemePurples,P as schemeRdBu,G as schemeRdGy,L as schemeRdPu,Y as schemeRdYlBu,g as schemeRdYlGn,nf as schemeReds,n as schemeSet1,l as schemeSet2,m as schemeSet3,S as schemeSpectral,i as schemeTableau10,X as schemeYlGn,Q as schemeYlGnBu,$ as schemeYlOrBr,ff as schemeYlOrRd}; +// d3-scale-chromatic@3.1.0 downloaded from https://ga.jspm.io/npm:d3-scale-chromatic@3.1.0/src/index.js + +import{interpolateRgbBasis as f,interpolateCubehelixLong as e}from"d3-interpolate";import{cubehelix as a,rgb as d}from"d3-color";function colors(f){var e=f.length/6|0,a=new Array(e),d=0;while(df(e[e.length-1]);var h=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(colors);var p=ramp$1(h);var u=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(colors);var w=ramp$1(u);var M=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(colors);var y=ramp$1(M);var A=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(colors);var P=ramp$1(A);var B=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(colors);var G=ramp$1(B);var R=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(colors);var Y=ramp$1(R);var x=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(colors);var O=ramp$1(x);var g=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(colors);var S=ramp$1(g);var C=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(colors);var I=ramp$1(C);var D=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(colors);var T=ramp$1(D);var k=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(colors);var V=ramp$1(k);var W=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(colors);var j=ramp$1(W);var q=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(colors);var z=ramp$1(q);var E=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(colors);var F=ramp$1(E);var H=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(colors);var J=ramp$1(H);var K=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(colors);var L=ramp$1(K);var N=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(colors);var Q=ramp$1(N);var U=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(colors);var X=ramp$1(U);var Z=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(colors);var $=ramp$1(Z);var _=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(colors);var ff=ramp$1(_);var ef=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(colors);var af=ramp$1(ef);var df=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(colors);var cf=ramp$1(df);var bf=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(colors);var rf=ramp$1(bf);var of=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(colors);var sf=ramp$1(of);var tf=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(colors);var nf=ramp$1(tf);var lf=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(colors);var mf=ramp$1(lf);var vf=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(colors);var hf=ramp$1(vf);function cividis(f){f=Math.max(0,Math.min(1,f));return"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-f*(35.34-f*(2381.73-f*(6402.7-f*(7024.72-f*2710.57)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+f*(170.73+f*(52.82-f*(131.46-f*(176.58-f*67.37)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+f*(442.36-f*(2482.43-f*(6167.24-f*(6614.94-f*2475.67)))))))+")"}var pf=e(a(300,.5,0),a(-240,.5,1));var uf=e(a(-100,.75,.35),a(80,1.5,.8));var wf=e(a(260,.75,.35),a(80,1.5,.8));var Mf=a();function rainbow(f){(f<0||f>1)&&(f-=Math.floor(f));var e=Math.abs(f-.5);Mf.h=360*f-100;Mf.s=1.5-1.5*e;Mf.l=.8-.9*e;return Mf+""}var yf=d(),Af=Math.PI/3,Pf=Math.PI*2/3;function sinebow(f){var e;f=(.5-f)*Math.PI;yf.r=255*(e=Math.sin(f))*e;yf.g=255*(e=Math.sin(f+Af))*e;yf.b=255*(e=Math.sin(f+Pf))*e;return yf+""}function turbo(f){f=Math.max(0,Math.min(1,f));return"rgb("+Math.max(0,Math.min(255,Math.round(34.61+f*(1172.33-f*(10793.56-f*(33300.12-f*(38394.49-f*14825.05)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+f*(557.33+f*(1225.33-f*(3574.96-f*(1073.77+f*707.56)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+f*(3211.1-f*(15327.97-f*(27814-f*(22569.18-f*6838.66)))))))+")"}function ramp(f){var e=f.length;return function(a){return f[Math.max(0,Math.min(e-1,Math.floor(a*e)))]}}var Bf=ramp(colors("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725"));var Gf=ramp(colors("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf"));var Rf=ramp(colors("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4"));var Yf=ramp(colors("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));export{cf as interpolateBlues,p as interpolateBrBG,T as interpolateBuGn,V as interpolateBuPu,cividis as interpolateCividis,wf as interpolateCool,pf as interpolateCubehelixDefault,j as interpolateGnBu,rf as interpolateGreens,sf as interpolateGreys,Rf as interpolateInferno,Gf as interpolateMagma,z as interpolateOrRd,hf as interpolateOranges,w as interpolatePRGn,y as interpolatePiYG,Yf as interpolatePlasma,J as interpolatePuBu,F as interpolatePuBuGn,P as interpolatePuOr,L as interpolatePuRd,nf as interpolatePurples,rainbow as interpolateRainbow,G as interpolateRdBu,Y as interpolateRdGy,Q as interpolateRdPu,O as interpolateRdYlBu,S as interpolateRdYlGn,mf as interpolateReds,sinebow as interpolateSinebow,I as interpolateSpectral,turbo as interpolateTurbo,Bf as interpolateViridis,uf as interpolateWarm,$ as interpolateYlGn,X as interpolateYlGnBu,ff as interpolateYlOrBr,af as interpolateYlOrRd,b as schemeAccent,df as schemeBlues,h as schemeBrBG,D as schemeBuGn,k as schemeBuPu,c as schemeCategory10,r as schemeDark2,W as schemeGnBu,bf as schemeGreens,of as schemeGreys,o as schemeObservable10,q as schemeOrRd,vf as schemeOranges,u as schemePRGn,s as schemePaired,t as schemePastel1,n as schemePastel2,M as schemePiYG,H as schemePuBu,E as schemePuBuGn,A as schemePuOr,K as schemePuRd,tf as schemePurples,B as schemeRdBu,R as schemeRdGy,N as schemeRdPu,x as schemeRdYlBu,g as schemeRdYlGn,lf as schemeReds,l as schemeSet1,m as schemeSet2,i as schemeSet3,C as schemeSpectral,v as schemeTableau10,Z as schemeYlGn,U as schemeYlGnBu,_ as schemeYlOrBr,ef as schemeYlOrRd}; diff --git a/vendor/javascript/d3-scale.js b/vendor/javascript/d3-scale.js index 2cdc5594..99910a88 100644 --- a/vendor/javascript/d3-scale.js +++ b/vendor/javascript/d3-scale.js @@ -1,2 +1,4 @@ +// d3-scale@4.0.2 downloaded from https://ga.jspm.io/npm:d3-scale@4.0.2/src/index.js + import{InternMap as n,range as e,bisect as t,tickStep as r,ticks as a,tickIncrement as i,quantileSorted as o,ascending as l,quantile as u}from"d3-array";import{interpolate as c,interpolateNumber as s,interpolateRound as f,piecewise as g}from"d3-interpolate";import{formatSpecifier as p,precisionFixed as h,precisionRound as m,precisionPrefix as d,formatPrefix as y,format as v}from"d3-format";import{timeTicks as w,timeTickInterval as M,timeYear as q,timeMonth as k,timeWeek as b,timeDay as x,timeHour as $,timeMinute as N,timeSecond as S,utcTicks as I,utcTickInterval as R,utcYear as A,utcMonth as L,utcWeek as P,utcDay as D,utcHour as E,utcMinute as F,utcSecond as z}from"d3-time";import{timeFormat as O,utcFormat as Q}from"d3-time-format";function initRange(n,e){switch(arguments.length){case 0:break;case 1:this.range(n);break;default:this.range(e).domain(n);break}return this}function initInterpolator(n,e){switch(arguments.length){case 0:break;case 1:"function"===typeof n?this.interpolator(n):this.range(n);break;default:this.domain(n);"function"===typeof e?this.interpolator(e):this.range(e);break}return this}const T=Symbol("implicit");function ordinal(){var e=new n,t=[],r=[],a=T;function scale(n){let i=e.get(n);if(void 0===i){if(a!==T)return a;e.set(n,i=t.push(n)-1)}return r[i%r.length]}scale.domain=function(r){if(!arguments.length)return t.slice();t=[],e=new n;for(const n of r)e.has(n)||e.set(n,t.push(n)-1);return scale};scale.range=function(n){return arguments.length?(r=Array.from(n),scale):r.slice()};scale.unknown=function(n){return arguments.length?(a=n,scale):a};scale.copy=function(){return ordinal(t,r).unknown(a)};initRange.apply(scale,arguments);return scale}function band(){var n,t,r=ordinal().unknown(void 0),a=r.domain,i=r.range,o=0,l=1,u=false,c=0,s=0,f=.5;delete r.unknown;function rescale(){var r=a().length,g=le&&(t=n,n=e,e=t);return function(t){return Math.max(n,Math.min(e,t))}}function bimap(n,e,t){var r=n[0],a=n[1],i=e[0],o=e[1];a2?polymap:bimap;a=i=null;return scale}function scale(e){return null==e||isNaN(e=+e)?t:(a||(a=r(o.map(n),l,u)))(n(g(e)))}scale.invert=function(t){return g(e((i||(i=r(l,o.map(n),s)))(t)))};scale.domain=function(n){return arguments.length?(o=Array.from(n,number$1),rescale()):o.slice()};scale.range=function(n){return arguments.length?(l=Array.from(n),rescale()):l.slice()};scale.rangeRound=function(n){return l=Array.from(n),u=f,rescale()};scale.clamp=function(n){return arguments.length?(g=!!n||identity$1,rescale()):g!==identity$1};scale.interpolate=function(n){return arguments.length?(u=n,rescale()):u};scale.unknown=function(n){return arguments.length?(t=n,scale):t};return function(t,r){n=t,e=r;return rescale()}}function continuous(){return transformer$2()(identity$1,identity$1)}function tickFormat(n,e,t,a){var i,o=r(n,e,t);a=p(null==a?",f":a);switch(a.type){case"s":var l=Math.max(Math.abs(n),Math.abs(e));null!=a.precision||isNaN(i=d(o,l))||(a.precision=i);return y(a,l);case"":case"e":case"g":case"p":case"r":null!=a.precision||isNaN(i=m(o,Math.max(Math.abs(n),Math.abs(e))))||(a.precision=i-("e"===a.type));break;case"f":case"%":null!=a.precision||isNaN(i=h(o))||(a.precision=i-2*("%"===a.type));break}return v(a)}function linearish(n){var e=n.domain;n.ticks=function(n){var t=e();return a(t[0],t[t.length-1],null==n?10:n)};n.tickFormat=function(n,t){var r=e();return tickFormat(r[0],r[r.length-1],null==n?10:n,t)};n.nice=function(t){null==t&&(t=10);var r=e();var a=0;var o=r.length-1;var l=r[a];var u=r[o];var c;var s;var f=10;if(u0){s=i(l,u,t);if(s===c){r[a]=l;r[o]=u;return e(r)}if(s>0){l=Math.floor(l/s)*s;u=Math.ceil(u/s)*s}else{if(!(s<0))break;l=Math.ceil(l*s)/s;u=Math.floor(u*s)/s}c=s}return n};return n}function linear(){var n=continuous();n.copy=function(){return copy$1(n,linear())};initRange.apply(n,arguments);return linearish(n)}function identity(n){var e;function scale(n){return null==n||isNaN(n=+n)?e:n}scale.invert=scale;scale.domain=scale.range=function(e){return arguments.length?(n=Array.from(e,number$1),scale):n.slice()};scale.unknown=function(n){return arguments.length?(e=n,scale):e};scale.copy=function(){return identity(n).unknown(e)};n=arguments.length?Array.from(n,number$1):[0,1];return linearish(scale)}function nice(n,e){n=n.slice();var t,r=0,a=n.length-1,i=n[r],o=n[a];if(oMath.pow(n,e)}function logp(n){return n===Math.E?Math.log:10===n&&Math.log10||2===n&&Math.log2||(n=Math.log(n),e=>Math.log(e)/n)}function reflect(n){return(e,t)=>-n(-e,t)}function loggish(n){const e=n(transformLog,transformExp);const t=e.domain;let r=10;let i;let o;function rescale(){i=logp(r),o=powp(r);if(t()[0]<0){i=reflect(i),o=reflect(o);n(transformLogn,transformExpn)}else n(transformLog,transformExp);return e}e.base=function(n){return arguments.length?(r=+n,rescale()):r};e.domain=function(n){return arguments.length?(t(n),rescale()):t()};e.ticks=n=>{const e=t();let l=e[0];let u=e[e.length-1];const c=u0)for(;s<=f;++s)for(g=1;gu)break;m.push(p)}}else for(;s<=f;++s)for(g=r-1;g>=1;--g){p=s>0?g/o(-s):g*o(s);if(!(pu)break;m.push(p)}}2*m.length{null==n&&(n=10);null==t&&(t=10===r?"s":",");if("function"!==typeof t){r%1||null!=(t=p(t)).precision||(t.trim=true);t=v(t)}if(Infinity===n)return t;const a=Math.max(1,r*n/e.ticks().length);return n=>{let e=n/o(Math.round(i(n)));e*rt(nice(t(),{floor:n=>o(Math.floor(i(n))),ceil:n=>o(Math.ceil(i(n)))}));return e}function log(){const n=loggish(transformer$2()).domain([1,10]);n.copy=()=>copy$1(n,log()).base(n.base());initRange.apply(n,arguments);return n}function transformSymlog(n){return function(e){return Math.sign(e)*Math.log1p(Math.abs(e/n))}}function transformSymexp(n){return function(e){return Math.sign(e)*Math.expm1(Math.abs(e))*n}}function symlogish(n){var e=1,t=n(transformSymlog(e),transformSymexp(e));t.constant=function(t){return arguments.length?n(transformSymlog(e=+t),transformSymexp(e)):e};return linearish(t)}function symlog(){var n=symlogish(transformer$2());n.copy=function(){return copy$1(n,symlog()).constant(n.constant())};return initRange.apply(n,arguments)}function transformPow(n){return function(e){return e<0?-Math.pow(-e,n):Math.pow(e,n)}}function transformSqrt(n){return n<0?-Math.sqrt(-n):Math.sqrt(n)}function transformSquare(n){return n<0?-n*n:n*n}function powish(n){var e=n(identity$1,identity$1),t=1;function rescale(){return 1===t?n(identity$1,identity$1):.5===t?n(transformSqrt,transformSquare):n(transformPow(t),transformPow(1/t))}e.exponent=function(n){return arguments.length?(t=+n,rescale()):t};return linearish(e)}function pow(){var n=powish(transformer$2());n.copy=function(){return copy$1(n,pow()).exponent(n.exponent())};initRange.apply(n,arguments);return n}function sqrt(){return pow.apply(null,arguments).exponent(.5)}function square(n){return Math.sign(n)*n*n}function unsquare(n){return Math.sign(n)*Math.sqrt(Math.abs(n))}function radial(){var n,e=continuous(),t=[0,1],r=false;function scale(t){var a=unsquare(e(t));return isNaN(a)?n:r?Math.round(a):a}scale.invert=function(n){return e.invert(square(n))};scale.domain=function(n){return arguments.length?(e.domain(n),scale):e.domain()};scale.range=function(n){return arguments.length?(e.range((t=Array.from(n,number$1)).map(square)),scale):t.slice()};scale.rangeRound=function(n){return scale.range(n).round(true)};scale.round=function(n){return arguments.length?(r=!!n,scale):r};scale.clamp=function(n){return arguments.length?(e.clamp(n),scale):e.clamp()};scale.unknown=function(e){return arguments.length?(n=e,scale):n};scale.copy=function(){return radial(e.domain(),t).round(r).clamp(e.clamp()).unknown(n)};initRange.apply(scale,arguments);return linearish(scale)}function quantile(){var n,e=[],r=[],a=[];function rescale(){var n=0,t=Math.max(1,r.length);a=new Array(t-1);while(++n0?a[t-1]:e[0],t=a?[i[a-1],r]:[i[t-1],i[t]]};scale.unknown=function(e){return arguments.length?(n=e,scale):scale};scale.thresholds=function(){return i.slice()};scale.copy=function(){return quantize().domain([e,r]).range(o).unknown(n)};return initRange.apply(linearish(scale),arguments)}function threshold(){var n,e=[.5],r=[0,1],a=1;function scale(i){return null!=i&&i<=i?r[t(e,i,0,a)]:n}scale.domain=function(n){return arguments.length?(e=Array.from(n),a=Math.min(e.length,r.length-1),scale):e.slice()};scale.range=function(n){return arguments.length?(r=Array.from(n),a=Math.min(e.length,r.length-1),scale):r.slice()};scale.invertExtent=function(n){var t=r.indexOf(n);return[e[t-1],e[t]]};scale.unknown=function(e){return arguments.length?(n=e,scale):n};scale.copy=function(){return threshold().domain(e).range(r).unknown(n)};return initRange.apply(scale,arguments)}function date(n){return new Date(n)}function number(n){return n instanceof Date?+n:+new Date(+n)}function calendar(n,e,t,r,a,i,o,l,u,c){var s=continuous(),f=s.invert,g=s.domain;var p=c(".%L"),h=c(":%S"),m=c("%I:%M"),d=c("%I %p"),y=c("%a %d"),v=c("%b %d"),w=c("%B"),M=c("%Y");function tickFormat(n){return(u(n)e(r/(n.length-1))))};scale.quantiles=function(e){return Array.from({length:e+1},((t,r)=>u(n,r/e)))};scale.copy=function(){return sequentialQuantile(e).domain(n)};return initInterpolator.apply(scale,arguments)}function transformer(){var n,e,t,r,a,i,o,l=0,u=.5,s=1,p=1,h=identity$1,m=false;function scale(n){return isNaN(n=+n)?o:(n=.5+((n=+i(n))-e)*(p*n=0&&"xmlns"!==(n=t.slice(0,r))&&(t=t.slice(r+1));return e.hasOwnProperty(n)?{space:e[n],local:t}:t}function creatorInherit(e){return function(){var n=this.ownerDocument,r=this.namespaceURI;return r===t&&n.documentElement.namespaceURI===t?n.createElement(e):n.createElementNS(r,e)}}function creatorFixed(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function creator(t){var e=namespace(t);return(e.local?creatorFixed:creatorInherit)(e)}function none(){}function selector(t){return null==t?none:function(){return this.querySelector(t)}}function selection_select(t){"function"!==typeof t&&(t=selector(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;i=A&&(A=w+1);while(!(g=y[A])&&++A<_);v._next=g||null}}s=new Selection(s,r);s._enter=c;s._exit=l;return s}function arraylike(t){return"object"===typeof t&&"length"in t?t:Array.from(t)}function selection_exit(){return new Selection(this._exit||this._groups.map(sparse),this._parents)}function selection_join(t,e,n){var r=this.enter(),i=this,o=this.exit();if("function"===typeof t){r=t(r);r&&(r=r.selection())}else r=r.append(t+"");if(null!=e){i=e(i);i&&(i=i.selection())}null==n?o.remove():n(o);return r&&i?r.merge(i).order():i}function selection_merge(t){var e=t.selection?t.selection():t;for(var n=this._groups,r=e._groups,i=n.length,o=r.length,s=Math.min(i,o),c=new Array(i),l=0;l=0;)if(r=i[o]){s&&4^r.compareDocumentPosition(s)&&s.parentNode.insertBefore(r,s);s=r}return this}function selection_sort(t){t||(t=ascending);function compareNode(e,n){return e&&n?t(e.__data__,n.__data__):!e-!n}for(var e=this._groups,n=e.length,r=new Array(n),i=0;ie?1:t>=e?0:NaN}function selection_call(){var t=arguments[0];arguments[0]=this;t.apply(null,arguments);return this}function selection_nodes(){return Array.from(this)}function selection_node(){for(var t=this._groups,e=0,n=t.length;e1?this.each((null==e?styleRemove:"function"===typeof e?styleFunction:styleConstant)(t,e,null==n?"":n)):styleValue(this.node(),t)}function styleValue(t,e){return t.style.getPropertyValue(e)||defaultView(t).getComputedStyle(t,null).getPropertyValue(e)}function propertyRemove(t){return function(){delete this[t]}}function propertyConstant(t,e){return function(){this[t]=e}}function propertyFunction(t,e){return function(){var n=e.apply(this,arguments);null==n?delete this[t]:this[t]=n}}function selection_property(t,e){return arguments.length>1?this.each((null==e?propertyRemove:"function"===typeof e?propertyFunction:propertyConstant)(t,e)):this.node()[t]}function classArray(t){return t.trim().split(/^|\s+/)}function classList(t){return t.classList||new ClassList(t)}function ClassList(t){this._node=t;this._names=classArray(t.getAttribute("class")||"")}ClassList.prototype={add:function(t){var e=this._names.indexOf(t);if(e<0){this._names.push(t);this._node.setAttribute("class",this._names.join(" "))}},remove:function(t){var e=this._names.indexOf(t);if(e>=0){this._names.splice(e,1);this._node.setAttribute("class",this._names.join(" "))}},contains:function(t){return this._names.indexOf(t)>=0}};function classedAdd(t,e){var n=classList(t),r=-1,i=e.length;while(++r=0&&(e=t.slice(n+1),t=t.slice(0,n));return{type:t,name:e}}))}function onRemove(t){return function(){var e=this.__on;if(e){for(var n,r=0,i=-1,o=e.length;rpointer(t,e)))}function selectAll(t){return"string"===typeof t?new Selection([document.querySelectorAll(t)],[document.documentElement]):new Selection([array(t)],i)}export{create,creator,local,matcher,namespace,e as namespaces,pointer,pointers,select,selectAll,selection,selector,selectorAll,styleValue as style,defaultView as window}; diff --git a/vendor/javascript/d3-shape.js b/vendor/javascript/d3-shape.js index a4307180..b4049e5a 100644 --- a/vendor/javascript/d3-shape.js +++ b/vendor/javascript/d3-shape.js @@ -1,2 +1,4 @@ -import{Path as t}from"d3-path";function constant(t){return function constant(){return t}}const n=Math.abs;const i=Math.atan2;const e=Math.cos;const s=Math.max;const o=Math.min;const a=Math.sin;const r=Math.sqrt;const h=1e-12;const l=Math.PI;const c=l/2;const _=2*l;function acos(t){return t>1?0:t<-1?l:Math.acos(t)}function asin(t){return t>=1?c:t<=-1?-c:Math.asin(t)}function withPath(n){let i=3;n.digits=function(t){if(!arguments.length)return i;if(null==t)i=null;else{const n=Math.floor(t);if(!(n>=0))throw new RangeError(`invalid digits: ${t}`);i=n}return n};return()=>new t(i)}function arcInnerRadius(t){return t.innerRadius}function arcOuterRadius(t){return t.outerRadius}function arcStartAngle(t){return t.startAngle}function arcEndAngle(t){return t.endAngle}function arcPadAngle(t){return t&&t.padAngle}function intersect(t,n,i,e,s,o,a,r){var l=i-t,c=e-n,_=a-s,u=r-o,f=u*l-_*c;if(!(f*f$*$+B*B&&(N=P,E=A);return{cx:N,cy:E,x01:-u,y01:-f,x11:N*(o/R-1),y11:E*(o/R-1)}}function arc(){var t=arcInnerRadius,s=arcOuterRadius,u=constant(0),f=null,p=arcStartAngle,d=arcEndAngle,v=arcPadAngle,m=null,T=withPath(arc);function arc(){var b,g,k=+t.apply(this,arguments),w=+s.apply(this,arguments),R=p.apply(this,arguments)-c,C=d.apply(this,arguments)-c,S=n(C-R),N=C>R;m||(m=b=T());wh)if(S>_-h){m.moveTo(w*e(R),w*a(R));m.arc(0,0,w,R,C,!N);if(k>h){m.moveTo(k*e(C),k*a(C));m.arc(0,0,k,C,R,N)}}else{var E,P,A=R,M=C,O=R,$=C,B=S,X=S,Y=v.apply(this,arguments)/2,z=Y>h&&(f?+f.apply(this,arguments):r(k*k+w*w)),L=o(n(w-k)/2,+u.apply(this,arguments)),I=L,q=L;if(z>h){var V=asin(z/k*a(Y)),D=asin(z/w*a(Y));(B-=2*V)>h?(V*=N?1:-1,O+=V,$-=V):(B=0,O=$=(R+C)/2);(X-=2*D)>h?(D*=N?1:-1,A+=D,M-=D):(X=0,A=M=(R+C)/2)}var j=w*e(A),H=w*a(A),W=k*e($),F=k*a($);if(L>h){var G,J=w*e(M),K=w*a(M),Q=k*e(O),U=k*a(O);if(Sh)if(q>h){E=cornerTangents(Q,U,j,H,w,q,N);P=cornerTangents(J,K,W,F,w,q,N);m.moveTo(E.cx+E.x01,E.cy+E.y01);if(qh&&B>h)if(I>h){E=cornerTangents(W,F,J,K,k,-I,N);P=cornerTangents(j,H,Q,U,k,-I,N);m.lineTo(E.cx+E.x01,E.cy+E.y01);if(I=_;--u)r.point(m[u],T[u]);r.lineEnd();r.areaEnd()}if(v){m[c]=+t(f,c,l),T[c]=+n(f,c,l);r.point(e?+e(f,c,l):m[c],i?+i(f,c,l):T[c])}}if(p)return r=null,p+""||null}function arealine(){return line().defined(s).curve(a).context(o)}area.x=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),e=null,area):t};area.x0=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),area):t};area.x1=function(t){return arguments.length?(e=null==t?null:"function"===typeof t?t:constant(+t),area):e};area.y=function(t){return arguments.length?(n="function"===typeof t?t:constant(+t),i=null,area):n};area.y0=function(t){return arguments.length?(n="function"===typeof t?t:constant(+t),area):n};area.y1=function(t){return arguments.length?(i=null==t?null:"function"===typeof t?t:constant(+t),area):i};area.lineX0=area.lineY0=function(){return arealine().x(t).y(n)};area.lineY1=function(){return arealine().x(t).y(i)};area.lineX1=function(){return arealine().x(e).y(n)};area.defined=function(t){return arguments.length?(s="function"===typeof t?t:constant(!!t),area):s};area.curve=function(t){return arguments.length?(a=t,null!=o&&(r=a(o)),area):a};area.context=function(t){return arguments.length?(null==t?o=r=null:r=a(o=t),area):o};return area}function descending$1(t,n){return nt?1:n>=t?0:NaN}function identity(t){return t}function pie(){var t=identity,n=descending$1,i=null,e=constant(0),s=constant(_),o=constant(0);function pie(a){var r,h,l,c,u,f=(a=array(a)).length,p=0,d=new Array(f),v=new Array(f),m=+e.apply(this,arguments),T=Math.min(_,Math.max(-_,s.apply(this,arguments)-m)),b=Math.min(Math.abs(T)/f,o.apply(this,arguments)),g=b*(T<0?-1:1);for(r=0;r0&&(p+=u);null!=n?d.sort((function(t,i){return n(v[t],v[i])})):null!=i&&d.sort((function(t,n){return i(a[t],a[n])}));for(r=0,l=p?(T-f*g)/p:0;r0?u*l:0)+g,v[h]={data:a[h],index:r,value:u,startAngle:m,endAngle:c,padAngle:b};return v}pie.value=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),pie):t};pie.sortValues=function(t){return arguments.length?(n=t,i=null,pie):n};pie.sort=function(t){return arguments.length?(i=t,n=null,pie):i};pie.startAngle=function(t){return arguments.length?(e="function"===typeof t?t:constant(+t),pie):e};pie.endAngle=function(t){return arguments.length?(s="function"===typeof t?t:constant(+t),pie):s};pie.padAngle=function(t){return arguments.length?(o="function"===typeof t?t:constant(+t),pie):o};return pie}var f=curveRadial(curveLinear);function Radial(t){this._curve=t}Radial.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};function curveRadial(t){function radial(n){return new Radial(t(n))}radial._curve=t;return radial}function lineRadial(t){var n=t.curve;t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function lineRadial$1(){return lineRadial(line().curve(f))}function areaRadial(){var t=area().curve(f),n=t.curve,i=t.lineX0,e=t.lineX1,s=t.lineY0,o=t.lineY1;t.angle=t.x,delete t.x;t.startAngle=t.x0,delete t.x0;t.endAngle=t.x1,delete t.x1;t.radius=t.y,delete t.y;t.innerRadius=t.y0,delete t.y0;t.outerRadius=t.y1,delete t.y1;t.lineStartAngle=function(){return lineRadial(i())},delete t.lineX0;t.lineEndAngle=function(){return lineRadial(e())},delete t.lineX1;t.lineInnerRadius=function(){return lineRadial(s())},delete t.lineY0;t.lineOuterRadius=function(){return lineRadial(o())},delete t.lineY1;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function pointRadial(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}class Bump{constructor(t,n){this._context=t;this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath();this._line=1-this._line}point(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n);break}this._x0=t,this._y0=n}}class BumpRadial{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){t=+t,n=+n;if(0===this._point)this._point=1;else{const i=pointRadial(this._x0,this._y0);const e=pointRadial(this._x0,this._y0=(this._y0+n)/2);const s=pointRadial(t,this._y0);const o=pointRadial(t,n);this._context.moveTo(...i);this._context.bezierCurveTo(...e,...s,...o)}this._x0=t,this._y0=n}}function bumpX(t){return new Bump(t,true)}function bumpY(t){return new Bump(t,false)}function bumpRadial(t){return new BumpRadial(t)}function linkSource(t){return t.source}function linkTarget(t){return t.target}function link(t){let n=linkSource,i=linkTarget,e=x,s=y,o=null,a=null,r=withPath(link);function link(){let h;const l=u.call(arguments);const c=n.apply(this,l);const _=i.apply(this,l);null==o&&(a=t(h=r()));a.lineStart();l[0]=c,a.point(+e.apply(this,l),+s.apply(this,l));l[0]=_,a.point(+e.apply(this,l),+s.apply(this,l));a.lineEnd();if(h)return a=null,h+""||null}link.source=function(t){return arguments.length?(n=t,link):n};link.target=function(t){return arguments.length?(i=t,link):i};link.x=function(t){return arguments.length?(e="function"===typeof t?t:constant(+t),link):e};link.y=function(t){return arguments.length?(s="function"===typeof t?t:constant(+t),link):s};link.context=function(n){return arguments.length?(null==n?o=a=null:a=t(o=n),link):o};return link}function linkHorizontal(){return link(bumpX)}function linkVertical(){return link(bumpY)}function linkRadial(){const t=link(bumpRadial);t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;return t}const p=r(3);var d={draw(t,n){const i=.59436*r(n+o(n/28,.75));const e=i/2;const s=e*p;t.moveTo(0,i);t.lineTo(0,-i);t.moveTo(-s,-e);t.lineTo(s,e);t.moveTo(-s,e);t.lineTo(s,-e)}};var v={draw(t,n){const i=r(n/l);t.moveTo(i,0);t.arc(0,0,i,0,_)}};var m={draw(t,n){const i=r(n/5)/2;t.moveTo(-3*i,-i);t.lineTo(-i,-i);t.lineTo(-i,-3*i);t.lineTo(i,-3*i);t.lineTo(i,-i);t.lineTo(3*i,-i);t.lineTo(3*i,i);t.lineTo(i,i);t.lineTo(i,3*i);t.lineTo(-i,3*i);t.lineTo(-i,i);t.lineTo(-3*i,i);t.closePath()}};const T=r(1/3);const b=2*T;var g={draw(t,n){const i=r(n/b);const e=i*T;t.moveTo(0,-i);t.lineTo(e,0);t.lineTo(0,i);t.lineTo(-e,0);t.closePath()}};var k={draw(t,n){const i=.62625*r(n);t.moveTo(0,-i);t.lineTo(i,0);t.lineTo(0,i);t.lineTo(-i,0);t.closePath()}};var w={draw(t,n){const i=.87559*r(n-o(n/7,2));t.moveTo(-i,0);t.lineTo(i,0);t.moveTo(0,i);t.lineTo(0,-i)}};var R={draw(t,n){const i=r(n);const e=-i/2;t.rect(e,e,i,i)}};var C={draw(t,n){const i=.4431*r(n);t.moveTo(i,i);t.lineTo(i,-i);t.lineTo(-i,-i);t.lineTo(-i,i);t.closePath()}};const S=.8908130915292852;const N=a(l/10)/a(7*l/10);const E=a(_/10)*N;const P=-e(_/10)*N;var A={draw(t,n){const i=r(n*S);const s=E*i;const o=P*i;t.moveTo(0,-i);t.lineTo(s,o);for(let n=1;n<5;++n){const r=_*n/5;const h=e(r);const l=a(r);t.lineTo(l*i,-h*i);t.lineTo(h*s-l*o,l*s+h*o)}t.closePath()}};const M=r(3);var O={draw(t,n){const i=-r(n/(3*M));t.moveTo(0,2*i);t.lineTo(-M*i,-i);t.lineTo(M*i,-i);t.closePath()}};const $=r(3);var B={draw(t,n){const i=.6824*r(n);const e=i/2;const s=i*$/2;t.moveTo(0,-i);t.lineTo(s,e);t.lineTo(-s,e);t.closePath()}};const X=-.5;const Y=r(3)/2;const z=1/r(12);const L=3*(z/2+1);var I={draw(t,n){const i=r(n/L);const e=i/2,s=i*z;const o=e,a=i*z+i;const h=-o,l=a;t.moveTo(e,s);t.lineTo(o,a);t.lineTo(h,l);t.lineTo(X*e-Y*s,Y*e+X*s);t.lineTo(X*o-Y*a,Y*o+X*a);t.lineTo(X*h-Y*l,Y*h+X*l);t.lineTo(X*e+Y*s,X*s-Y*e);t.lineTo(X*o+Y*a,X*a-Y*o);t.lineTo(X*h+Y*l,X*l-Y*h);t.closePath()}};var q={draw(t,n){const i=.6189*r(n-o(n/6,1.7));t.moveTo(-i,-i);t.lineTo(i,i);t.moveTo(-i,i);t.lineTo(i,-i)}};const V=[v,m,g,R,A,O,I];const D=[v,w,q,B,d,C,k];function Symbol$1(t,n){let i=null,e=withPath(symbol);t="function"===typeof t?t:constant(t||v);n="function"===typeof n?n:constant(void 0===n?64:+n);function symbol(){let s;i||(i=s=e());t.apply(this,arguments).draw(i,+n.apply(this,arguments));if(s)return i=null,s+""||null}symbol.type=function(n){return arguments.length?(t="function"===typeof n?n:constant(n),symbol):t};symbol.size=function(t){return arguments.length?(n="function"===typeof t?t:constant(+t),symbol):n};symbol.context=function(t){return arguments.length?(i=null==t?null:t,symbol):i};return symbol}function noop(){}function point$3(t,n,i){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+i)/6)}function Basis(t){this._context=t}Basis.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN;this._point=0},lineEnd:function(){switch(this._point){case 3:point$3(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||0!==this._line&&1===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:point$3(this,t,n);break}this._x0=this._x1,this._x1=t;this._y0=this._y1,this._y1=n}};function basis(t){return new Basis(t)}function BasisClosed(t){this._context=t}BasisClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN;this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2);this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3);this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3);this._context.closePath();break;case 3:this.point(this._x2,this._y2);this.point(this._x3,this._y3);this.point(this._x4,this._y4);break}},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._x2=t,this._y2=n;break;case 1:this._point=2;this._x3=t,this._y3=n;break;case 2:this._point=3;this._x4=t,this._y4=n;this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:point$3(this,t,n);break}this._x0=this._x1,this._x1=t;this._y0=this._y1,this._y1=n}};function basisClosed(t){return new BasisClosed(t)}function BasisOpen(t){this._context=t}BasisOpen.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN;this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var i=(this._x0+4*this._x1+t)/6,e=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(i,e):this._context.moveTo(i,e);break;case 3:this._point=4;default:point$3(this,t,n);break}this._x0=this._x1,this._x1=t;this._y0=this._y1,this._y1=n}};function basisOpen(t){return new BasisOpen(t)}function Bundle(t,n){this._basis=new Basis(t);this._beta=n}Bundle.prototype={lineStart:function(){this._x=[];this._y=[];this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,i=t.length-1;if(i>0){var e,s=t[0],o=n[0],a=t[i]-s,r=n[i]-o,h=-1;while(++h<=i){e=h/i;this._basis.point(this._beta*t[h]+(1-this._beta)*(s+e*a),this._beta*n[h]+(1-this._beta)*(o+e*r))}}this._x=this._y=null;this._basis.lineEnd()},point:function(t,n){this._x.push(+t);this._y.push(+n)}};var j=function custom(t){function bundle(n){return 1===t?new Basis(n):new Bundle(n,t)}bundle.beta=function(t){return custom(+t)};return bundle}(.85);function point$2(t,n,i){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-i),t._x2,t._y2)}function Cardinal(t,n){this._context=t;this._k=(1-n)/6}Cardinal.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN;this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:point$2(this,this._x1,this._y1);break}(this._line||0!==this._line&&1===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;this._x1=t,this._y1=n;break;case 2:this._point=3;default:point$2(this,t,n);break}this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var H=function custom(t){function cardinal(n){return new Cardinal(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalClosed(t,n){this._context=t;this._k=(1-n)/6}CardinalClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN;this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3);this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3);this._context.closePath();break;case 3:this.point(this._x3,this._y3);this.point(this._x4,this._y4);this.point(this._x5,this._y5);break}},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._x3=t,this._y3=n;break;case 1:this._point=2;this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3;this._x5=t,this._y5=n;break;default:point$2(this,t,n);break}this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var W=function custom(t){function cardinal(n){return new CardinalClosed(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalOpen(t,n){this._context=t;this._k=(1-n)/6}CardinalOpen.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN;this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:point$2(this,t,n);break}this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var F=function custom(t){function cardinal(n){return new CardinalOpen(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function point$1(t,n,i){var e=t._x1,s=t._y1,o=t._x2,a=t._y2;if(t._l01_a>h){var r=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,l=3*t._l01_a*(t._l01_a+t._l12_a);e=(e*r-t._x0*t._l12_2a+t._x2*t._l01_2a)/l;s=(s*r-t._y0*t._l12_2a+t._y2*t._l01_2a)/l}if(t._l23_a>h){var c=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,_=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*c+t._x1*t._l23_2a-n*t._l12_2a)/_;a=(a*c+t._y1*t._l23_2a-i*t._l12_2a)/_}t._context.bezierCurveTo(e,s,o,a,t._x2,t._y2)}function CatmullRom(t,n){this._context=t;this._alpha=n}CatmullRom.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN;this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2);break}(this._line||0!==this._line&&1===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;if(this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:point$1(this,t,n);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a;this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a;this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var G=function custom(t){function catmullRom(n){return t?new CatmullRom(n,t):new Cardinal(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomClosed(t,n){this._context=t;this._alpha=n}CatmullRomClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN;this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3);this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3);this._context.closePath();break;case 3:this.point(this._x3,this._y3);this.point(this._x4,this._y4);this.point(this._x5,this._y5);break}},point:function(t,n){t=+t,n=+n;if(this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1;this._x3=t,this._y3=n;break;case 1:this._point=2;this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3;this._x5=t,this._y5=n;break;default:point$1(this,t,n);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a;this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a;this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var J=function custom(t){function catmullRom(n){return t?new CatmullRomClosed(n,t):new CardinalClosed(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomOpen(t,n){this._context=t;this._alpha=n}CatmullRomOpen.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN;this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){t=+t,n=+n;if(this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:point$1(this,t,n);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a;this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a;this._x0=this._x1,this._x1=this._x2,this._x2=t;this._y0=this._y1,this._y1=this._y2,this._y2=n}};var K=function custom(t){function catmullRom(n){return t?new CatmullRomOpen(n,t):new CardinalOpen(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function LinearClosed(t){this._context=t}LinearClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){this._point=0},lineEnd:function(){this._point&&this._context.closePath()},point:function(t,n){t=+t,n=+n;this._point?this._context.lineTo(t,n):(this._point=1,this._context.moveTo(t,n))}};function linearClosed(t){return new LinearClosed(t)}function sign(t){return t<0?-1:1}function slope3(t,n,i){var e=t._x1-t._x0,s=n-t._x1,o=(t._y1-t._y0)/(e||s<0&&-0),a=(i-t._y1)/(s||e<0&&-0),r=(o*s+a*e)/(e+s);return(sign(o)+sign(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(r))||0}function slope2(t,n){var i=t._x1-t._x0;return i?(3*(t._y1-t._y0)/i-n)/2:n}function point(t,n,i){var e=t._x0,s=t._y0,o=t._x1,a=t._y1,r=(o-e)/3;t._context.bezierCurveTo(e+r,s+r*n,o-r,a-r*i,o,a)}function MonotoneX(t){this._context=t}MonotoneX.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN;this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:point(this,this._t0,slope2(this,this._t0));break}(this._line||0!==this._line&&1===this._point)&&this._context.closePath();this._line=1-this._line},point:function(t,n){var i=NaN;t=+t,n=+n;if(t!==this._x1||n!==this._y1){switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;point(this,slope2(this,i=slope3(this,t,n)),i);break;default:point(this,this._t0,i=slope3(this,t,n));break}this._x0=this._x1,this._x1=t;this._y0=this._y1,this._y1=n;this._t0=i}}};function MonotoneY(t){this._context=new ReflectContext(t)}(MonotoneY.prototype=Object.create(MonotoneX.prototype)).point=function(t,n){MonotoneX.prototype.point.call(this,n,t)};function ReflectContext(t){this._context=t}ReflectContext.prototype={moveTo:function(t,n){this._context.moveTo(n,t)},closePath:function(){this._context.closePath()},lineTo:function(t,n){this._context.lineTo(n,t)},bezierCurveTo:function(t,n,i,e,s,o){this._context.bezierCurveTo(n,t,e,i,o,s)}};function monotoneX(t){return new MonotoneX(t)}function monotoneY(t){return new MonotoneY(t)}function Natural(t){this._context=t}Natural.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x=[];this._y=[]},lineEnd:function(){var t=this._x,n=this._y,i=t.length;if(i){this._line?this._context.lineTo(t[0],n[0]):this._context.moveTo(t[0],n[0]);if(2===i)this._context.lineTo(t[1],n[1]);else{var e=controlPoints(t),s=controlPoints(n);for(var o=0,a=1;a=0;--n)s[n]=(a[n]-s[n+1])/o[n];o[e-1]=(t[e]+s[e-1])/2;for(n=0;n=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){t=+t,n=+n;switch(this._point){case 0:this._point=1;this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0){this._context.lineTo(this._x,n);this._context.lineTo(t,n)}else{var i=this._x*(1-this._t)+t*this._t;this._context.lineTo(i,this._y);this._context.lineTo(i,n)}break}this._x=t,this._y=n}};function step(t){return new Step(t,.5)}function stepBefore(t){return new Step(t,0)}function stepAfter(t){return new Step(t,1)}function none$1(t,n){if((s=t.length)>1)for(var i,e,s,o=1,a=t[n[0]],r=a.length;o=0)i[n]=n;return i}function stackValue(t,n){return t[n]}function stackSeries(t){const n=[];n.key=t;return n}function stack(){var t=constant([]),n=none,i=none$1,e=stackValue;function stack(s){var o,a,r=Array.from(t.apply(this,arguments),stackSeries),h=r.length,l=-1;for(const t of s)for(o=0,++l;o0){for(var i,e,s,o=0,a=t[0].length;o0)for(var i,e,s,o,a,r,h=0,l=t[n[0]].length;h0?(e[0]=o,e[1]=o+=s):s<0?(e[1]=a,e[0]=a+=s):(e[0]=0,e[1]=s)}function silhouette(t,n){if((i=t.length)>0){for(var i,e=0,s=t[n[0]],o=s.length;e0&&(e=(i=t[n[0]]).length)>0){for(var i,e,s,o=0,a=1;ao&&(o=n,e=i);return e}function ascending(t){var n=t.map(sum);return none(t).sort((function(t,i){return n[t]-n[i]}))}function sum(t){var n,i=0,e=-1,s=t.length;while(++e1?0:t<-1?_:Math.acos(t)}function asin(t){return t>=1?u:t<=-1?-u:Math.asin(t)}function arcInnerRadius(t){return t.innerRadius}function arcOuterRadius(t){return t.outerRadius}function arcStartAngle(t){return t.startAngle}function arcEndAngle(t){return t.endAngle}function arcPadAngle(t){return t&&t.padAngle}function intersect(t,n,i,e,a,s,o,r){var l=i-t,h=e-n,_=o-a,u=r-s,f=u*l-_*h;if(!(f*f$*$+B*B&&(S=O,N=A);return{cx:S,cy:N,x01:-u,y01:-f,x11:S*(a/C-1),y11:N*(a/C-1)}}function arc(){var t=arcInnerRadius,o=arcOuterRadius,p=constant(0),d=null,v=arcStartAngle,m=arcEndAngle,k=arcPadAngle,g=null;function arc(){var b,T,R=+t.apply(this||n,arguments),C=+o.apply(this||n,arguments),w=v.apply(this||n,arguments)-u,M=m.apply(this||n,arguments)-u,S=e(M-w),N=M>w;g||(g=b=i.path());Cc)if(S>f-c){g.moveTo(C*s(w),C*l(w));g.arc(0,0,C,w,M,!N);if(R>c){g.moveTo(R*s(M),R*l(M));g.arc(0,0,R,M,w,N)}}else{var O=w,A=M,E=w,P=M,$=S,B=S,q=k.apply(this||n,arguments)/2,z=q>c&&(d?+d.apply(this||n,arguments):h(R*R+C*C)),L=r(e(C-R)/2,+p.apply(this||n,arguments)),X=L,Y=L,V,I;if(z>c){var D=asin(z/R*l(q)),H=asin(z/C*l(q));($-=2*D)>c?(D*=N?1:-1,E+=D,P-=D):($=0,E=P=(w+M)/2);(B-=2*H)>c?(H*=N?1:-1,O+=H,A-=H):(B=0,O=A=(w+M)/2)}var W=C*s(O),j=C*l(O),F=R*s(P),G=R*l(P);if(L>c){var J=C*s(A),K=C*l(A),Q=R*s(E),U=R*l(E),Z;if(S<_&&(Z=intersect(W,j,Q,U,J,K,F,G))){var tt=W-Z[0],nt=j-Z[1],it=J-Z[0],et=K-Z[1],at=1/l(acos((tt*it+nt*et)/(h(tt*tt+nt*nt)*h(it*it+et*et)))/2),st=h(Z[0]*Z[0]+Z[1]*Z[1]);X=r(L,(R-st)/(at-1));Y=r(L,(C-st)/(at+1))}}if(B>c)if(Y>c){V=cornerTangents(Q,U,W,j,C,Y,N);I=cornerTangents(J,K,F,G,C,Y,N);g.moveTo(V.cx+V.x01,V.cy+V.y01);if(Yc&&$>c)if(X>c){V=cornerTangents(F,G,J,K,R,-X,N);I=cornerTangents(W,j,Q,U,R,-X,N);g.lineTo(V.cx+V.x01,V.cy+V.y01);if(X=_;--u)l.point(m[u],k[u]);l.lineEnd();l.areaEnd()}if(d){m[c]=+t(p,c,h),k[c]=+e(p,c,h);l.point(n?+n(p,c,h):m[c],a?+a(p,c,h):k[c])}}if(v)return l=null,v+""||null}function arealine(){return line().defined(s).curve(r).context(o)}area.x=function(i){return arguments.length?(t="function"===typeof i?i:constant(+i),n=null,area):t};area.x0=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),area):t};area.x1=function(t){return arguments.length?(n=null==t?null:"function"===typeof t?t:constant(+t),area):n};area.y=function(t){return arguments.length?(e="function"===typeof t?t:constant(+t),a=null,area):e};area.y0=function(t){return arguments.length?(e="function"===typeof t?t:constant(+t),area):e};area.y1=function(t){return arguments.length?(a=null==t?null:"function"===typeof t?t:constant(+t),area):a};area.lineX0=area.lineY0=function(){return arealine().x(t).y(e)};area.lineY1=function(){return arealine().x(t).y(a)};area.lineX1=function(){return arealine().x(n).y(e)};area.defined=function(t){return arguments.length?(s="function"===typeof t?t:constant(!!t),area):s};area.curve=function(t){return arguments.length?(r=t,null!=o&&(l=r(o)),area):r};area.context=function(t){return arguments.length?(null==t?o=l=null:l=r(o=t),area):o};return area}function descending(t,n){return nt?1:n>=t?0:NaN}function identity(t){return t}function pie(){var t=identity,i=descending,e=null,a=constant(0),s=constant(f),o=constant(0);function pie(r){var l,h=r.length,c,_,u=0,p=new Array(h),d=new Array(h),v=+a.apply(this||n,arguments),m=Math.min(f,Math.max(-f,s.apply(this||n,arguments)-v)),k,g=Math.min(Math.abs(m)/h,o.apply(this||n,arguments)),b=g*(m<0?-1:1),T;for(l=0;l0&&(u+=T);null!=i?p.sort((function(t,n){return i(d[t],d[n])})):null!=e&&p.sort((function(t,n){return e(r[t],r[n])}));for(l=0,_=u?(m-h*b)/u:0;l0?T*_:0)+b,d[c]={data:r[c],index:l,value:T,startAngle:v,endAngle:k,padAngle:g};return d}pie.value=function(n){return arguments.length?(t="function"===typeof n?n:constant(+n),pie):t};pie.sortValues=function(t){return arguments.length?(i=t,e=null,pie):i};pie.sort=function(t){return arguments.length?(e=t,i=null,pie):e};pie.startAngle=function(t){return arguments.length?(a="function"===typeof t?t:constant(+t),pie):a};pie.endAngle=function(t){return arguments.length?(s="function"===typeof t?t:constant(+t),pie):s};pie.padAngle=function(t){return arguments.length?(o="function"===typeof t?t:constant(+t),pie):o};return pie}var p=curveRadial(curveLinear);function Radial(t){(this||n)._curve=t}Radial.prototype={areaStart:function(){(this||n)._curve.areaStart()},areaEnd:function(){(this||n)._curve.areaEnd()},lineStart:function(){(this||n)._curve.lineStart()},lineEnd:function(){(this||n)._curve.lineEnd()},point:function(t,i){(this||n)._curve.point(i*Math.sin(t),i*-Math.cos(t))}};function curveRadial(t){function radial(n){return new Radial(t(n))}radial._curve=t;return radial}function lineRadial(t){var n=t.curve;t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function lineRadial$1(){return lineRadial(line().curve(p))}function areaRadial(){var t=area().curve(p),n=t.curve,i=t.lineX0,e=t.lineX1,a=t.lineY0,s=t.lineY1;t.angle=t.x,delete t.x;t.startAngle=t.x0,delete t.x0;t.endAngle=t.x1,delete t.x1;t.radius=t.y,delete t.y;t.innerRadius=t.y0,delete t.y0;t.outerRadius=t.y1,delete t.y1;t.lineStartAngle=function(){return lineRadial(i())},delete t.lineX0;t.lineEndAngle=function(){return lineRadial(e())},delete t.lineX1;t.lineInnerRadius=function(){return lineRadial(a())},delete t.lineY0;t.lineOuterRadius=function(){return lineRadial(s())},delete t.lineY1;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function pointRadial(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}var d=Array.prototype.slice;function linkSource(t){return t.source}function linkTarget(t){return t.target}function link(t){var e=linkSource,a=linkTarget,s=x,o=y,r=null;function link(){var l,h=d.call(arguments),c=e.apply(this||n,h),_=a.apply(this||n,h);r||(r=l=i.path());t(r,+s.apply(this||n,(h[0]=c,h)),+o.apply(this||n,h),+s.apply(this||n,(h[0]=_,h)),+o.apply(this||n,h));if(l)return r=null,l+""||null}link.source=function(t){return arguments.length?(e=t,link):e};link.target=function(t){return arguments.length?(a=t,link):a};link.x=function(t){return arguments.length?(s="function"===typeof t?t:constant(+t),link):s};link.y=function(t){return arguments.length?(o="function"===typeof t?t:constant(+t),link):o};link.context=function(t){return arguments.length?(r=null==t?null:t,link):r};return link}function curveHorizontal(t,n,i,e,a){t.moveTo(n,i);t.bezierCurveTo(n=(n+e)/2,i,n,a,e,a)}function curveVertical(t,n,i,e,a){t.moveTo(n,i);t.bezierCurveTo(n,i=(i+a)/2,e,i,e,a)}function curveRadial$1(t,n,i,e,a){var s=pointRadial(n,i),o=pointRadial(n,i=(i+a)/2),r=pointRadial(e,i),l=pointRadial(e,a);t.moveTo(s[0],s[1]);t.bezierCurveTo(o[0],o[1],r[0],r[1],l[0],l[1])}function linkHorizontal(){return link(curveHorizontal)}function linkVertical(){return link(curveVertical)}function linkRadial(){var t=link(curveRadial$1);t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;return t}var v={draw:function(t,n){var i=Math.sqrt(n/_);t.moveTo(i,0);t.arc(0,0,i,0,f)}};var m={draw:function(t,n){var i=Math.sqrt(n/5)/2;t.moveTo(-3*i,-i);t.lineTo(-i,-i);t.lineTo(-i,-3*i);t.lineTo(i,-3*i);t.lineTo(i,-i);t.lineTo(3*i,-i);t.lineTo(3*i,i);t.lineTo(i,i);t.lineTo(i,3*i);t.lineTo(-i,3*i);t.lineTo(-i,i);t.lineTo(-3*i,i);t.closePath()}};var k=Math.sqrt(1/3),g=2*k;var b={draw:function(t,n){var i=Math.sqrt(n/g),e=i*k;t.moveTo(0,-i);t.lineTo(e,0);t.lineTo(0,i);t.lineTo(-e,0);t.closePath()}};var T=.8908130915292852,R=Math.sin(_/10)/Math.sin(7*_/10),C=Math.sin(f/10)*R,w=-Math.cos(f/10)*R;var M={draw:function(t,n){var i=Math.sqrt(n*T),e=C*i,a=w*i;t.moveTo(0,-i);t.lineTo(e,a);for(var s=1;s<5;++s){var o=f*s/5,r=Math.cos(o),l=Math.sin(o);t.lineTo(l*i,-r*i);t.lineTo(r*e-l*a,l*e+r*a)}t.closePath()}};var S={draw:function(t,n){var i=Math.sqrt(n),e=-i/2;t.rect(e,e,i,i)}};var N=Math.sqrt(3);var O={draw:function(t,n){var i=-Math.sqrt(n/(3*N));t.moveTo(0,2*i);t.lineTo(-N*i,-i);t.lineTo(N*i,-i);t.closePath()}};var A=-.5,E=Math.sqrt(3)/2,P=1/Math.sqrt(12),$=3*(P/2+1);var B={draw:function(t,n){var i=Math.sqrt(n/$),e=i/2,a=i*P,s=e,o=i*P+i,r=-s,l=o;t.moveTo(e,a);t.lineTo(s,o);t.lineTo(r,l);t.lineTo(A*e-E*a,E*e+A*a);t.lineTo(A*s-E*o,E*s+A*o);t.lineTo(A*r-E*l,E*r+A*l);t.lineTo(A*e+E*a,A*a-E*e);t.lineTo(A*s+E*o,A*o-E*s);t.lineTo(A*r+E*l,A*l-E*r);t.closePath()}};var q=[v,m,b,S,M,O,B];function symbol(){var t=constant(v),e=constant(64),a=null;function symbol(){var s;a||(a=s=i.path());t.apply(this||n,arguments).draw(a,+e.apply(this||n,arguments));if(s)return a=null,s+""||null}symbol.type=function(n){return arguments.length?(t="function"===typeof n?n:constant(n),symbol):t};symbol.size=function(t){return arguments.length?(e="function"===typeof t?t:constant(+t),symbol):e};symbol.context=function(t){return arguments.length?(a=null==t?null:t,symbol):a};return symbol}function noop(){}function point(t,n,i){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+i)/6)}function Basis(t){(this||n)._context=t}Basis.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 3:point(this||n,(this||n)._x1,(this||n)._y1);case 2:(this||n)._context.lineTo((this||n)._x1,(this||n)._y1);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._context.lineTo((5*(this||n)._x0+(this||n)._x1)/6,(5*(this||n)._y0+(this||n)._y1)/6);default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basis(t){return new Basis(t)}function BasisClosed(t){(this||n)._context=t}BasisClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);(this||n)._context.closePath();break;case 2:(this||n)._context.moveTo(((this||n)._x2+2*(this||n)._x3)/3,((this||n)._y2+2*(this||n)._y3)/3);(this||n)._context.lineTo(((this||n)._x3+2*(this||n)._x2)/3,((this||n)._y3+2*(this||n)._y2)/3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x2,(this||n)._y2);this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);break}},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x2=t,(this||n)._y2=i;break;case 1:(this||n)._point=2;(this||n)._x3=t,(this||n)._y3=i;break;case 2:(this||n)._point=3;(this||n)._x4=t,(this||n)._y4=i;(this||n)._context.moveTo(((this||n)._x0+4*(this||n)._x1+t)/6,((this||n)._y0+4*(this||n)._y1+i)/6);break;default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basisClosed(t){return new BasisClosed(t)}function BasisOpen(t){(this||n)._context=t}BasisOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=NaN;(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;var e=((this||n)._x0+4*(this||n)._x1+t)/6,a=((this||n)._y0+4*(this||n)._y1+i)/6;(this||n)._line?(this||n)._context.lineTo(e,a):(this||n)._context.moveTo(e,a);break;case 3:(this||n)._point=4;default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basisOpen(t){return new BasisOpen(t)}function Bundle(t,i){(this||n)._basis=new Basis(t);(this||n)._beta=i}Bundle.prototype={lineStart:function(){(this||n)._x=[];(this||n)._y=[];(this||n)._basis.lineStart()},lineEnd:function(){var t=(this||n)._x,i=(this||n)._y,e=t.length-1;if(e>0){var a=t[0],s=i[0],o=t[e]-a,r=i[e]-s,l=-1,h;while(++l<=e){h=l/e;(this||n)._basis.point((this||n)._beta*t[l]+(1-(this||n)._beta)*(a+h*o),(this||n)._beta*i[l]+(1-(this||n)._beta)*(s+h*r))}}(this||n)._x=(this||n)._y=null;(this||n)._basis.lineEnd()},point:function(t,i){(this||n)._x.push(+t);(this||n)._y.push(+i)}};var z=function custom(t){function bundle(n){return 1===t?new Basis(n):new Bundle(n,t)}bundle.beta=function(t){return custom(+t)};return bundle}(.85);function point$1(t,n,i){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-i),t._x2,t._y2)}function Cardinal(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}Cardinal.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x2,(this||n)._y2);break;case 3:point$1(this||n,(this||n)._x1,(this||n)._y1);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;(this||n)._x1=t,(this||n)._y1=i;break;case 2:(this||n)._point=3;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var L=function custom(t){function cardinal(n){return new Cardinal(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalClosed(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}CardinalClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._x5=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=(this||n)._y5=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 2:(this||n)._context.lineTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);this.point((this||n)._x5,(this||n)._y5);break}},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x3=t,(this||n)._y3=i;break;case 1:(this||n)._point=2;(this||n)._context.moveTo((this||n)._x4=t,(this||n)._y4=i);break;case 2:(this||n)._point=3;(this||n)._x5=t,(this||n)._y5=i;break;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var X=function custom(t){function cardinal(n){return new CardinalClosed(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalOpen(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}CardinalOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._line?(this||n)._context.lineTo((this||n)._x2,(this||n)._y2):(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);break;case 3:(this||n)._point=4;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var Y=function custom(t){function cardinal(n){return new CardinalOpen(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function point$2(t,n,i){var e=t._x1,a=t._y1,s=t._x2,o=t._y2;if(t._l01_a>c){var r=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,l=3*t._l01_a*(t._l01_a+t._l12_a);e=(e*r-t._x0*t._l12_2a+t._x2*t._l01_2a)/l;a=(a*r-t._y0*t._l12_2a+t._y2*t._l01_2a)/l}if(t._l23_a>c){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,_=3*t._l23_a*(t._l23_a+t._l12_a);s=(s*h+t._x1*t._l23_2a-n*t._l12_2a)/_;o=(o*h+t._y1*t._l23_2a-i*t._l12_2a)/_}t._context.bezierCurveTo(e,a,s,o,t._x2,t._y2)}function CatmullRom(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRom.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x2,(this||n)._y2);break;case 3:this.point((this||n)._x2,(this||n)._y2);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var V=function custom(t){function catmullRom(n){return t?new CatmullRom(n,t):new Cardinal(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomClosed(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRomClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._x5=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=(this||n)._y5=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 2:(this||n)._context.lineTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);this.point((this||n)._x5,(this||n)._y5);break}},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x3=t,(this||n)._y3=i;break;case 1:(this||n)._point=2;(this||n)._context.moveTo((this||n)._x4=t,(this||n)._y4=i);break;case 2:(this||n)._point=3;(this||n)._x5=t,(this||n)._y5=i;break;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var I=function custom(t){function catmullRom(n){return t?new CatmullRomClosed(n,t):new CardinalClosed(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomOpen(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRomOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._line?(this||n)._context.lineTo((this||n)._x2,(this||n)._y2):(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);break;case 3:(this||n)._point=4;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var D=function custom(t){function catmullRom(n){return t?new CatmullRomOpen(n,t):new CardinalOpen(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function LinearClosed(t){(this||n)._context=t}LinearClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._point=0},lineEnd:function(){(this||n)._point&&(this||n)._context.closePath()},point:function(t,i){t=+t,i=+i;(this||n)._point?(this||n)._context.lineTo(t,i):((this||n)._point=1,(this||n)._context.moveTo(t,i))}};function linearClosed(t){return new LinearClosed(t)}function sign(t){return t<0?-1:1}function slope3(t,n,i){var e=t._x1-t._x0,a=n-t._x1,s=(t._y1-t._y0)/(e||a<0&&-0),o=(i-t._y1)/(a||e<0&&-0),r=(s*a+o*e)/(e+a);return(sign(s)+sign(o))*Math.min(Math.abs(s),Math.abs(o),.5*Math.abs(r))||0}function slope2(t,n){var i=t._x1-t._x0;return i?(3*(t._y1-t._y0)/i-n)/2:n}function point$3(t,n,i){var e=t._x0,a=t._y0,s=t._x1,o=t._y1,r=(s-e)/3;t._context.bezierCurveTo(e+r,a+r*n,s-r,o-r*i,s,o)}function MonotoneX(t){(this||n)._context=t}MonotoneX.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=(this||n)._t0=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x1,(this||n)._y1);break;case 3:point$3(this||n,(this||n)._t0,slope2(this||n,(this||n)._t0));break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){var e=NaN;t=+t,i=+i;if(t!==(this||n)._x1||i!==(this||n)._y1){switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;point$3(this||n,slope2(this||n,e=slope3(this||n,t,i)),e);break;default:point$3(this||n,(this||n)._t0,e=slope3(this||n,t,i));break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i;(this||n)._t0=e}}};function MonotoneY(t){(this||n)._context=new ReflectContext(t)}(MonotoneY.prototype=Object.create(MonotoneX.prototype)).point=function(t,i){MonotoneX.prototype.point.call(this||n,i,t)};function ReflectContext(t){(this||n)._context=t}ReflectContext.prototype={moveTo:function(t,i){(this||n)._context.moveTo(i,t)},closePath:function(){(this||n)._context.closePath()},lineTo:function(t,i){(this||n)._context.lineTo(i,t)},bezierCurveTo:function(t,i,e,a,s,o){(this||n)._context.bezierCurveTo(i,t,a,e,o,s)}};function monotoneX(t){return new MonotoneX(t)}function monotoneY(t){return new MonotoneY(t)}function Natural(t){(this||n)._context=t}Natural.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x=[];(this||n)._y=[]},lineEnd:function(){var t=(this||n)._x,i=(this||n)._y,e=t.length;if(e){(this||n)._line?(this||n)._context.lineTo(t[0],i[0]):(this||n)._context.moveTo(t[0],i[0]);if(2===e)(this||n)._context.lineTo(t[1],i[1]);else{var a=controlPoints(t),s=controlPoints(i);for(var o=0,r=1;r=0;--n)a[n]=(o[n]-a[n+1])/s[n];s[i-1]=(t[i]+a[i-1])/2;for(n=0;n=0&&((this||n)._t=1-(this||n)._t,(this||n)._line=1-(this||n)._line)},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;default:if((this||n)._t<=0){(this||n)._context.lineTo((this||n)._x,i);(this||n)._context.lineTo(t,i)}else{var e=(this||n)._x*(1-(this||n)._t)+t*(this||n)._t;(this||n)._context.lineTo(e,(this||n)._y);(this||n)._context.lineTo(e,i)}break}(this||n)._x=t,(this||n)._y=i}};function step(t){return new Step(t,.5)}function stepBefore(t){return new Step(t,0)}function stepAfter(t){return new Step(t,1)}function none(t,n){if((o=t.length)>1)for(var i=1,e,a,s=t[n[0]],o,r=s.length;i=0)i[n]=n;return i}function stackValue(t,n){return t[n]}function stack(){var t=constant([]),i=none$1,e=none,a=stackValue;function stack(s){var o=t.apply(this||n,arguments),r,l=s.length,h=o.length,c=new Array(h),_;for(r=0;r0){for(var i,e,a=0,s=t[0].length,o;a0)for(var i,e=0,a,s,o,r,l,h=t[n[0]].length;e0?(a[0]=o,a[1]=o+=s):s<0?(a[1]=r,a[0]=r+=s):(a[0]=0,a[1]=s)}function silhouette(t,n){if((a=t.length)>0){for(var i=0,e=t[n[0]],a,s=e.length;i0&&(s=(a=t[n[0]]).length)>0){for(var i=0,e=1,a,s,o;es&&(s=a,i=n);return i}function ascending(t){var n=t.map(sum);return none$1(t).sort((function(t,i){return n[t]-n[i]}))}function sum(t){var n=0,i=-1,e=t.length,a;while(++i53)return null;"w"in c||(c.w=1);if("Z"in c){f=utcDate(newDate(c.y,0,1)),i=f.getUTCDay();f=i>4||0===i?e.ceil(f):e(f);f=r.offset(f,7*(c.V-1));c.y=f.getUTCFullYear();c.m=f.getUTCMonth();c.d=f.getUTCDate()+(c.w+6)%7}else{f=localDate(newDate(c.y,0,1)),i=f.getDay();f=i>4||0===i?t.ceil(f):t(f);f=n.offset(f,7*(c.V-1));c.y=f.getFullYear();c.m=f.getMonth();c.d=f.getDate()+(c.w+6)%7}}else if("W"in c||"U"in c){"w"in c||(c.w="u"in c?c.u%7:"W"in c?1:0);i="Z"in c?utcDate(newDate(c.y,0,1)).getUTCDay():localDate(newDate(c.y,0,1)).getDay();c.m=0;c.d="W"in c?(c.w+6)%7+7*c.W-(i+5)%7:c.w+7*c.U-(i+6)%7}if("Z"in c){c.H+=c.Z/100|0;c.M+=c.Z%100;return utcDate(c)}return localDate(c)}}function parseSpecifier(e,r,t,n){var a,o,u=0,f=r.length,i=t.length;while(u=i)return-1;a=r.charCodeAt(u++);if(37===a){a=r.charAt(u++);o=W[a in m?r.charAt(u++):a];if(!o||(n=o(e,t,n))<0)return-1}else if(a!=t.charCodeAt(n++))return-1}return n}function parsePeriod(e,r,t){var n=p.exec(r.slice(t));return n?(e.p=y.get(n[0].toLowerCase()),t+n[0].length):-1}function parseShortWeekday(e,r,t){var n=g.exec(r.slice(t));return n?(e.w=U.get(n[0].toLowerCase()),t+n[0].length):-1}function parseWeekday(e,r,t){var n=T.exec(r.slice(t));return n?(e.w=h.get(n[0].toLowerCase()),t+n[0].length):-1}function parseShortMonth(e,r,t){var n=S.exec(r.slice(t));return n?(e.m=D.get(n[0].toLowerCase()),t+n[0].length):-1}function parseMonth(e,r,t){var n=M.exec(r.slice(t));return n?(e.m=C.get(n[0].toLowerCase()),t+n[0].length):-1}function parseLocaleDateTime(e,r,t){return parseSpecifier(e,o,r,t)}function parseLocaleDate(e,r,t){return parseSpecifier(e,u,r,t)}function parseLocaleTime(e,r,t){return parseSpecifier(e,f,r,t)}function formatShortWeekday(e){return s[e.getDay()]}function formatWeekday(e){return c[e.getDay()]}function formatShortMonth(e){return d[e.getMonth()]}function formatMonth(e){return l[e.getMonth()]}function formatPeriod(e){return i[+(e.getHours()>=12)]}function formatQuarter(e){return 1+~~(e.getMonth()/3)}function formatUTCShortWeekday(e){return s[e.getUTCDay()]}function formatUTCWeekday(e){return c[e.getUTCDay()]}function formatUTCShortMonth(e){return d[e.getUTCMonth()]}function formatUTCMonth(e){return l[e.getUTCMonth()]}function formatUTCPeriod(e){return i[+(e.getUTCHours()>=12)]}function formatUTCQuarter(e){return 1+~~(e.getUTCMonth()/3)}return{format:function(e){var r=newFormat(e+="",v);r.toString=function(){return e};return r},parse:function(e){var r=newParse(e+="",false);r.toString=function(){return e};return r},utcFormat:function(e){var r=newFormat(e+="",w);r.toString=function(){return e};return r},utcParse:function(e){var r=newParse(e+="",true);r.toString=function(){return e};return r}}}var m={"-":"",_:" ",0:"0"},s=/^\s*\d+/,l=/^%/,d=/[\\^$*+?|[\]().{}]/g;function pad(e,r,t){var n=e<0?"-":"",a=(n?-e:e)+"",o=a.length;return n+(o[e.toLowerCase(),r])))}function parseWeekdayNumberSunday(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.w=+n[0],t+n[0].length):-1}function parseWeekdayNumberMonday(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.u=+n[0],t+n[0].length):-1}function parseWeekNumberSunday(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.U=+n[0],t+n[0].length):-1}function parseWeekNumberISO(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.V=+n[0],t+n[0].length):-1}function parseWeekNumberMonday(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.W=+n[0],t+n[0].length):-1}function parseFullYear(e,r,t){var n=s.exec(r.slice(t,t+4));return n?(e.y=+n[0],t+n[0].length):-1}function parseYear(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.y=+n[0]+(+n[0]>68?1900:2e3),t+n[0].length):-1}function parseZone(e,r,t){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(r.slice(t,t+6));return n?(e.Z=n[1]?0:-(n[2]+(n[3]||"00")),t+n[0].length):-1}function parseQuarter(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.q=3*n[0]-3,t+n[0].length):-1}function parseMonthNumber(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.m=n[0]-1,t+n[0].length):-1}function parseDayOfMonth(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.d=+n[0],t+n[0].length):-1}function parseDayOfYear(e,r,t){var n=s.exec(r.slice(t,t+3));return n?(e.m=0,e.d=+n[0],t+n[0].length):-1}function parseHour24(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.H=+n[0],t+n[0].length):-1}function parseMinutes(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.M=+n[0],t+n[0].length):-1}function parseSeconds(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.S=+n[0],t+n[0].length):-1}function parseMilliseconds(e,r,t){var n=s.exec(r.slice(t,t+3));return n?(e.L=+n[0],t+n[0].length):-1}function parseMicroseconds(e,r,t){var n=s.exec(r.slice(t,t+6));return n?(e.L=Math.floor(n[0]/1e3),t+n[0].length):-1}function parseLiteralPercent(e,r,t){var n=l.exec(r.slice(t,t+1));return n?t+n[0].length:-1}function parseUnixTimestamp(e,r,t){var n=s.exec(r.slice(t));return n?(e.Q=+n[0],t+n[0].length):-1}function parseUnixTimestampSeconds(e,r,t){var n=s.exec(r.slice(t));return n?(e.s=+n[0],t+n[0].length):-1}function formatDayOfMonth(e,r){return pad(e.getDate(),r,2)}function formatHour24(e,r){return pad(e.getHours(),r,2)}function formatHour12(e,r){return pad(e.getHours()%12||12,r,2)}function formatDayOfYear(e,r){return pad(1+n.count(a(e),e),r,3)}function formatMilliseconds(e,r){return pad(e.getMilliseconds(),r,3)}function formatMicroseconds(e,r){return formatMilliseconds(e,r)+"000"}function formatMonthNumber(e,r){return pad(e.getMonth()+1,r,2)}function formatMinutes(e,r){return pad(e.getMinutes(),r,2)}function formatSeconds(e,r){return pad(e.getSeconds(),r,2)}function formatWeekdayNumberMonday(e){var r=e.getDay();return 0===r?7:r}function formatWeekNumberSunday(e,r){return pad(o.count(a(e)-1,e),r,2)}function dISO(e){var r=e.getDay();return r>=4||0===r?u(e):u.ceil(e)}function formatWeekNumberISO(e,r){e=dISO(e);return pad(u.count(a(e),e)+(4===a(e).getDay()),r,2)}function formatWeekdayNumberSunday(e){return e.getDay()}function formatWeekNumberMonday(e,r){return pad(t.count(a(e)-1,e),r,2)}function formatYear(e,r){return pad(e.getFullYear()%100,r,2)}function formatYearISO(e,r){e=dISO(e);return pad(e.getFullYear()%100,r,2)}function formatFullYear(e,r){return pad(e.getFullYear()%1e4,r,4)}function formatFullYearISO(e,r){var t=e.getDay();e=t>=4||0===t?u(e):u.ceil(e);return pad(e.getFullYear()%1e4,r,4)}function formatZone(e){var r=e.getTimezoneOffset();return(r>0?"-":(r*=-1,"+"))+pad(r/60|0,"0",2)+pad(r%60,"0",2)}function formatUTCDayOfMonth(e,r){return pad(e.getUTCDate(),r,2)}function formatUTCHour24(e,r){return pad(e.getUTCHours(),r,2)}function formatUTCHour12(e,r){return pad(e.getUTCHours()%12||12,r,2)}function formatUTCDayOfYear(e,t){return pad(1+r.count(f(e),e),t,3)}function formatUTCMilliseconds(e,r){return pad(e.getUTCMilliseconds(),r,3)}function formatUTCMicroseconds(e,r){return formatUTCMilliseconds(e,r)+"000"}function formatUTCMonthNumber(e,r){return pad(e.getUTCMonth()+1,r,2)}function formatUTCMinutes(e,r){return pad(e.getUTCMinutes(),r,2)}function formatUTCSeconds(e,r){return pad(e.getUTCSeconds(),r,2)}function formatUTCWeekdayNumberMonday(e){var r=e.getUTCDay();return 0===r?7:r}function formatUTCWeekNumberSunday(e,r){return pad(i.count(f(e)-1,e),r,2)}function UTCdISO(e){var r=e.getUTCDay();return r>=4||0===r?c(e):c.ceil(e)}function formatUTCWeekNumberISO(e,r){e=UTCdISO(e);return pad(c.count(f(e),e)+(4===f(e).getUTCDay()),r,2)}function formatUTCWeekdayNumberSunday(e){return e.getUTCDay()}function formatUTCWeekNumberMonday(r,t){return pad(e.count(f(r)-1,r),t,2)}function formatUTCYear(e,r){return pad(e.getUTCFullYear()%100,r,2)}function formatUTCYearISO(e,r){e=UTCdISO(e);return pad(e.getUTCFullYear()%100,r,2)}function formatUTCFullYear(e,r){return pad(e.getUTCFullYear()%1e4,r,4)}function formatUTCFullYearISO(e,r){var t=e.getUTCDay();e=t>=4||0===t?c(e):c.ceil(e);return pad(e.getUTCFullYear()%1e4,r,4)}function formatUTCZone(){return"+0000"}function formatLiteralPercent(){return"%"}function formatUnixTimestamp(e){return+e}function formatUnixTimestampSeconds(e){return Math.floor(+e/1e3)}var p;var y;var T;var h;var g;defaultLocale({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});function defaultLocale(e){p=formatLocale(e);y=p.format;T=p.parse;h=p.utcFormat;g=p.utcParse;return p}var U="%Y-%m-%dT%H:%M:%S.%LZ";function formatIsoNative(e){return e.toISOString()}var M=Date.prototype.toISOString?formatIsoNative:h(U);function parseIsoNative(e){var r=new Date(e);return isNaN(r)?null:r}var C=+new Date("2000-01-01T00:00:00.000Z")?parseIsoNative:g(U);export{M as isoFormat,C as isoParse,y as timeFormat,defaultLocale as timeFormatDefaultLocale,formatLocale as timeFormatLocale,T as timeParse,h as utcFormat,g as utcParse}; diff --git a/vendor/javascript/d3-time.js b/vendor/javascript/d3-time.js index 7f5538e3..1f61a2ef 100644 --- a/vendor/javascript/d3-time.js +++ b/vendor/javascript/d3-time.js @@ -1,2 +1,4 @@ +// d3-time@3.1.0 downloaded from https://ga.jspm.io/npm:d3-time@3.1.0/src/index.js + import{bisector as e,tickStep as t}from"d3-array";const n=new Date,s=new Date;function timeInterval(e,t,r,a){function interval(t){return e(t=0===arguments.length?new Date:new Date(+t)),t}interval.floor=t=>(e(t=new Date(+t)),t);interval.ceil=n=>(e(n=new Date(n-1)),t(n,1),e(n),n);interval.round=e=>{const t=interval(e),n=interval.ceil(e);return e-t(t(e=new Date(+e),null==n?1:Math.floor(n)),e);interval.range=(n,s,r)=>{const a=[];n=interval.ceil(n);r=null==r?1:Math.floor(r);if(!(n0))return a;let o;do{a.push(o=new Date(+n)),t(n,r),e(n)}while(otimeInterval((t=>{if(t>=t)while(e(t),!n(t))t.setTime(t-1)}),((e,s)=>{if(e>=e)if(s<0)while(++s<=0)while(t(e,-1),!n(e));else while(--s>=0)while(t(e,1),!n(e));}));if(r){interval.count=(t,a)=>{n.setTime(+t),s.setTime(+a);e(n),e(s);return Math.floor(r(n,s))};interval.every=e=>{e=Math.floor(e);return isFinite(e)&&e>0?e>1?interval.filter(a?t=>a(t)%e===0:t=>interval.count(0,t)%e===0):interval:null}}return interval}const r=timeInterval((()=>{}),((e,t)=>{e.setTime(+e+t)}),((e,t)=>t-e));r.every=e=>{e=Math.floor(e);return isFinite(e)&&e>0?e>1?timeInterval((t=>{t.setTime(Math.floor(t/e)*e)}),((t,n)=>{t.setTime(+t+n*e)}),((t,n)=>(n-t)/e)):r:null};const a=r.range;const o=1e3;const l=60*o;const i=60*l;const c=24*i;const u=7*c;const g=30*c;const T=365*c;const m=timeInterval((e=>{e.setTime(e-e.getMilliseconds())}),((e,t)=>{e.setTime(+e+t*o)}),((e,t)=>(t-e)/o),(e=>e.getUTCSeconds()));const v=m.range;const f=timeInterval((e=>{e.setTime(e-e.getMilliseconds()-e.getSeconds()*o)}),((e,t)=>{e.setTime(+e+t*l)}),((e,t)=>(t-e)/l),(e=>e.getMinutes()));const C=f.range;const U=timeInterval((e=>{e.setUTCSeconds(0,0)}),((e,t)=>{e.setTime(+e+t*l)}),((e,t)=>(t-e)/l),(e=>e.getUTCMinutes()));const M=U.range;const h=timeInterval((e=>{e.setTime(e-e.getMilliseconds()-e.getSeconds()*o-e.getMinutes()*l)}),((e,t)=>{e.setTime(+e+t*i)}),((e,t)=>(t-e)/i),(e=>e.getHours()));const d=h.range;const k=timeInterval((e=>{e.setUTCMinutes(0,0,0)}),((e,t)=>{e.setTime(+e+t*i)}),((e,t)=>(t-e)/i),(e=>e.getUTCHours()));const D=k.range;const y=timeInterval((e=>e.setHours(0,0,0,0)),((e,t)=>e.setDate(e.getDate()+t)),((e,t)=>(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*l)/c),(e=>e.getDate()-1));const F=y.range;const I=timeInterval((e=>{e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+t)}),((e,t)=>(t-e)/c),(e=>e.getUTCDate()-1));const Y=I.range;const W=timeInterval((e=>{e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+t)}),((e,t)=>(t-e)/c),(e=>Math.floor(e/c)));const w=W.range;function timeWeekday(e){return timeInterval((t=>{t.setDate(t.getDate()-(t.getDay()+7-e)%7);t.setHours(0,0,0,0)}),((e,t)=>{e.setDate(e.getDate()+7*t)}),((e,t)=>(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*l)/u))}const H=timeWeekday(0);const S=timeWeekday(1);const p=timeWeekday(2);const z=timeWeekday(3);const O=timeWeekday(4);const x=timeWeekday(5);const b=timeWeekday(6);const j=H.range;const q=S.range;const A=p.range;const B=z.range;const E=O.range;const G=x.range;const J=b.range;function utcWeekday(e){return timeInterval((t=>{t.setUTCDate(t.getUTCDate()-(t.getUTCDay()+7-e)%7);t.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+7*t)}),((e,t)=>(t-e)/u))}const K=utcWeekday(0);const L=utcWeekday(1);const N=utcWeekday(2);const P=utcWeekday(3);const Q=utcWeekday(4);const R=utcWeekday(5);const V=utcWeekday(6);const X=K.range;const Z=L.range;const $=N.range;const _=P.range;const ee=Q.range;const te=R.range;const ne=V.range;const se=timeInterval((e=>{e.setDate(1);e.setHours(0,0,0,0)}),((e,t)=>{e.setMonth(e.getMonth()+t)}),((e,t)=>t.getMonth()-e.getMonth()+12*(t.getFullYear()-e.getFullYear())),(e=>e.getMonth()));const re=se.range;const ae=timeInterval((e=>{e.setUTCDate(1);e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCMonth(e.getUTCMonth()+t)}),((e,t)=>t.getUTCMonth()-e.getUTCMonth()+12*(t.getUTCFullYear()-e.getUTCFullYear())),(e=>e.getUTCMonth()));const oe=ae.range;const le=timeInterval((e=>{e.setMonth(0,1);e.setHours(0,0,0,0)}),((e,t)=>{e.setFullYear(e.getFullYear()+t)}),((e,t)=>t.getFullYear()-e.getFullYear()),(e=>e.getFullYear()));le.every=e=>isFinite(e=Math.floor(e))&&e>0?timeInterval((t=>{t.setFullYear(Math.floor(t.getFullYear()/e)*e);t.setMonth(0,1);t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n*e)})):null;const ie=le.range;const ce=timeInterval((e=>{e.setUTCMonth(0,1);e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCFullYear(e.getUTCFullYear()+t)}),((e,t)=>t.getUTCFullYear()-e.getUTCFullYear()),(e=>e.getUTCFullYear()));ce.every=e=>isFinite(e=Math.floor(e))&&e>0?timeInterval((t=>{t.setUTCFullYear(Math.floor(t.getUTCFullYear()/e)*e);t.setUTCMonth(0,1);t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n*e)})):null;const ue=ce.range;function ticker(n,s,a,v,f,C){const U=[[m,1,o],[m,5,5*o],[m,15,15*o],[m,30,30*o],[C,1,l],[C,5,5*l],[C,15,15*l],[C,30,30*l],[f,1,i],[f,3,3*i],[f,6,6*i],[f,12,12*i],[v,1,c],[v,2,2*c],[a,1,u],[s,1,g],[s,3,3*g],[n,1,T]];function ticks(e,t,n){const s=te)).right(U,l);if(i===U.length)return n.every(t(s/T,a/T,o));if(0===i)return r.every(Math.max(t(s,a,o),1));const[c,u]=U[l/U[i-1][2]=0&&i._call.call(void 0,e);i=i._next}--n}function wake(){a=(l=s.now())+u;n=i=0;try{timerFlush()}finally{n=0;nap();a=0}}function poke(){var t=s.now(),e=t-l;e>o&&(u-=e,l=t)}function nap(){var n,i,r=t,o=Infinity;while(r)if(r._call){o>r._time&&(o=r._time);n=r,r=r._next}else{i=r._next,r._next=null;r=n?n._next=i:t=i}e=n;sleep(o)}function sleep(t){if(!n){i&&(i=clearTimeout(i));var e=t-a;if(e>24){t{i.stop();t(n+e)}),e,n);return i}function interval(t,e,n){var i=new Timer,r=e;if(null==e)return i.restart(t,e,n),i;i._restart=i.restart;i.restart=function(t,e,n){e=+e,n=null==n?now():+n;i._restart((function tick(o){o+=r;i._restart(tick,r+=e,n);t(o)}),e,n)};i.restart(t,e,n);return i}export{interval,now,timeout,timer,timerFlush}; diff --git a/vendor/javascript/d3-transition.js b/vendor/javascript/d3-transition.js index f47dc858..e57459d3 100644 --- a/vendor/javascript/d3-transition.js +++ b/vendor/javascript/d3-transition.js @@ -1,2 +1,4 @@ +// d3-transition@3.0.1 downloaded from https://ga.jspm.io/npm:d3-transition@3.0.1/src/index.js + import{namespace as t,matcher as n,selector as e,selectorAll as r,selection as i,style as o}from"d3-selection";import{dispatch as a}from"d3-dispatch";import{timer as s,timeout as u,now as l}from"d3-timer";import{interpolateNumber as c,interpolateRgb as f,interpolateString as h,interpolateTransformSvg as _,interpolateTransformCss as v}from"d3-interpolate";import{color as d}from"d3-color";import{easeCubicInOut as p}from"d3-ease";var y=a("start","end","cancel","interrupt");var w=[];var m=0;var g=1;var T=2;var x=3;var C=4;var A=5;var N=6;function schedule(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};create(t,e,{name:n,index:r,group:i,on:y,tween:w,time:o.time,delay:o.delay,duration:o.duration,ease:o.ease,timer:null,state:m})}function init(t,n){var e=get(t,n);if(e.state>m)throw new Error("too late; already scheduled");return e}function set(t,n){var e=get(t,n);if(e.state>x)throw new Error("too late; already running");return e}function get(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function create(t,n,e){var r,i=t.__transition;i[n]=e;e.timer=s(schedule,0,e.time);function schedule(t){e.state=g;e.timer.restart(start,e.delay,e.time);e.delay<=t&&start(t-e.delay)}function start(o){var a,s,l,c;if(e.state!==g)return stop();for(a in i){c=i[a];if(c.name===e.name){if(c.state===x)return u(start);if(c.state===C){c.state=N;c.timer.stop();c.on.call("interrupt",t,t.__data__,c.index,c.group);delete i[a]}else if(+aT&&e.state=0&&(t=t.slice(0,n));return!t||"start"===t}))}function onFunction(t,n,e){var r,i,o=start(n)?init:set;return function(){var a=o(this,t),s=a.on;s!==r&&(i=(r=s).copy()).on(n,e);a.on=i}}function transition_on(t,n){var e=this._id;return arguments.length<2?get(this.node(),e).on.on(t):this.each(onFunction(e,t,n))}function removeFunction(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}function transition_remove(){return this.on("end.remove",removeFunction(this._id))}function transition_select(t){var n=this._name,r=this._id;"function"!==typeof t&&(t=e(t));for(var i=this._groups,o=i.length,a=new Array(o),s=0;sg&&e.name===n)return new Transition([[t]],I,n,+r)}return null}export{active,interrupt,transition}; diff --git a/vendor/javascript/d3-zoom.js b/vendor/javascript/d3-zoom.js index f155a61d..2d59d2ff 100644 --- a/vendor/javascript/d3-zoom.js +++ b/vendor/javascript/d3-zoom.js @@ -1,2 +1,4 @@ +// d3-zoom@3.0.0 downloaded from https://ga.jspm.io/npm:d3-zoom@3.0.0/src/index.js + import{dispatch as t}from"d3-dispatch";import{dragDisable as o,dragEnable as e}from"d3-drag";import{interpolateZoom as n}from"d3-interpolate";import{select as i,pointer as r}from"d3-selection";import{interrupt as u}from"d3-transition";var constant=t=>()=>t;function ZoomEvent(t,{sourceEvent:o,target:e,transform:n,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:true,configurable:true},sourceEvent:{value:o,enumerable:true,configurable:true},target:{value:e,enumerable:true,configurable:true},transform:{value:n,enumerable:true,configurable:true},_:{value:i}})}function Transform(t,o,e){this.k=t;this.x=o;this.y=e}Transform.prototype={constructor:Transform,scale:function(t){return 1===t?this:new Transform(this.k*t,this.x,this.y)},translate:function(t,o){return 0===t&0===o?this:new Transform(this.k,this.x+this.k*t,this.y+this.k*o)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var s=new Transform(1,0,0);transform.prototype=Transform.prototype;function transform(t){while(!t.__zoom)if(!(t=t.parentNode))return s;return t.__zoom}function nopropagation(t){t.stopImmediatePropagation()}function noevent(t){t.preventDefault();t.stopImmediatePropagation()}function defaultFilter(t){return(!t.ctrlKey||"wheel"===t.type)&&!t.button}function defaultExtent(){var t=this;if(t instanceof SVGElement){t=t.ownerSVGElement||t;if(t.hasAttribute("viewBox")){t=t.viewBox.baseVal;return[[t.x,t.y],[t.x+t.width,t.y+t.height]]}return[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}return[[0,0],[t.clientWidth,t.clientHeight]]}function defaultTransform(){return this.__zoom||s}function defaultWheelDelta(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function defaultTouchable(){return navigator.maxTouchPoints||"ontouchstart"in this}function defaultConstrain(t,o,e){var n=t.invertX(o[0][0])-e[0][0],i=t.invertX(o[1][0])-e[1][0],r=t.invertY(o[0][1])-e[0][1],u=t.invertY(o[1][1])-e[1][1];return t.translate(i>n?(n+i)/2:Math.min(0,n)||Math.max(0,i),u>r?(r+u)/2:Math.min(0,r)||Math.max(0,u))}function zoom(){var a,h,c,l=defaultFilter,m=defaultExtent,f=defaultConstrain,p=defaultWheelDelta,d=defaultTouchable,v=[0,Infinity],z=[[-Infinity,-Infinity],[Infinity,Infinity]],y=250,g=n,_=t("start","zoom","end"),w=500,T=150,k=0,x=10;function zoom(t){t.property("__zoom",defaultTransform).on("wheel.zoom",wheeled,{passive:false}).on("mousedown.zoom",mousedowned).on("dblclick.zoom",dblclicked).filter(d).on("touchstart.zoom",touchstarted).on("touchmove.zoom",touchmoved).on("touchend.zoom touchcancel.zoom",touchended).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}zoom.transform=function(t,o,e,n){var i=t.selection?t.selection():t;i.property("__zoom",defaultTransform);t!==i?schedule(t,o,e,n):i.interrupt().each((function(){gesture(this,arguments).event(n).start().zoom(null,"function"===typeof o?o.apply(this,arguments):o).end()}))};zoom.scaleBy=function(t,o,e,n){zoom.scaleTo(t,(function(){var t=this.__zoom.k,e="function"===typeof o?o.apply(this,arguments):o;return t*e}),e,n)};zoom.scaleTo=function(t,o,e,n){zoom.transform(t,(function(){var t=m.apply(this,arguments),n=this.__zoom,i=null==e?centroid(t):"function"===typeof e?e.apply(this,arguments):e,r=n.invert(i),u="function"===typeof o?o.apply(this,arguments):o;return f(translate(scale(n,u),i,r),t,z)}),e,n)};zoom.translateBy=function(t,o,e,n){zoom.transform(t,(function(){return f(this.__zoom.translate("function"===typeof o?o.apply(this,arguments):o,"function"===typeof e?e.apply(this,arguments):e),m.apply(this,arguments),z)}),null,n)};zoom.translateTo=function(t,o,e,n,i){zoom.transform(t,(function(){var t=m.apply(this,arguments),i=this.__zoom,r=null==n?centroid(t):"function"===typeof n?n.apply(this,arguments):n;return f(s.translate(r[0],r[1]).scale(i.k).translate("function"===typeof o?-o.apply(this,arguments):-o,"function"===typeof e?-e.apply(this,arguments):-e),t,z)}),n,i)};function scale(t,o){o=Math.max(v[0],Math.min(v[1],o));return o===t.k?t:new Transform(o,t.x,t.y)}function translate(t,o,e){var n=o[0]-e[0]*t.k,i=o[1]-e[1]*t.k;return n===t.x&&i===t.y?t:new Transform(t.k,n,i)}function centroid(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function schedule(t,o,e,n){t.on("start.zoom",(function(){gesture(this,arguments).event(n).start()})).on("interrupt.zoom end.zoom",(function(){gesture(this,arguments).event(n).end()})).tween("zoom",(function(){var t=this,i=arguments,r=gesture(t,i).event(n),u=m.apply(t,i),s=null==e?centroid(u):"function"===typeof e?e.apply(t,i):e,a=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),h=t.__zoom,c="function"===typeof o?o.apply(t,i):o,l=g(h.invert(s).concat(a/h.k),c.invert(s).concat(a/c.k));return function(t){if(1===t)t=c;else{var o=l(t),e=a/o[2];t=new Transform(e,s[0]-o[0]*e,s[1]-o[1]*e)}r.zoom(null,t)}}))}function gesture(t,o,e){return!e&&t.__zooming||new Gesture(t,o)}function Gesture(t,o){this.that=t;this.args=o;this.active=0;this.sourceEvent=null;this.extent=m.apply(t,o);this.taps=0}Gesture.prototype={event:function(t){t&&(this.sourceEvent=t);return this},start:function(){if(1===++this.active){this.that.__zooming=this;this.emit("start")}return this},zoom:function(t,o){this.mouse&&"mouse"!==t&&(this.mouse[1]=o.invert(this.mouse[0]));this.touch0&&"touch"!==t&&(this.touch0[1]=o.invert(this.touch0[0]));this.touch1&&"touch"!==t&&(this.touch1[1]=o.invert(this.touch1[0]));this.that.__zoom=o;this.emit("zoom");return this},end:function(){if(0===--this.active){delete this.that.__zooming;this.emit("end")}return this},emit:function(t){var o=i(this.that).datum();_.call(t,this.that,new ZoomEvent(t,{sourceEvent:this.sourceEvent,target:zoom,type:t,transform:this.that.__zoom,dispatch:_}),o)}};function wheeled(t,...o){if(l.apply(this,arguments)){var e=gesture(this,o).event(t),n=this.__zoom,i=Math.max(v[0],Math.min(v[1],n.k*Math.pow(2,p.apply(this,arguments)))),s=r(t);if(e.wheel){e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=n.invert(e.mouse[0]=s));clearTimeout(e.wheel)}else{if(n.k===i)return;e.mouse=[s,n.invert(s)];u(this);e.start()}noevent(t);e.wheel=setTimeout(wheelidled,T);e.zoom("mouse",f(translate(scale(n,i),e.mouse[0],e.mouse[1]),e.extent,z))}function wheelidled(){e.wheel=null;e.end()}}function mousedowned(t,...n){if(!c&&l.apply(this,arguments)){var s=t.currentTarget,a=gesture(this,n,true).event(t),h=i(t.view).on("mousemove.zoom",mousemoved,true).on("mouseup.zoom",mouseupped,true),m=r(t,s),p=t.clientX,d=t.clientY;o(t.view);nopropagation(t);a.mouse=[m,this.__zoom.invert(m)];u(this);a.start()}function mousemoved(t){noevent(t);if(!a.moved){var o=t.clientX-p,e=t.clientY-d;a.moved=o*o+e*e>k}a.event(t).zoom("mouse",f(translate(a.that.__zoom,a.mouse[0]=r(t,s),a.mouse[1]),a.extent,z))}function mouseupped(t){h.on("mousemove.zoom mouseup.zoom",null);e(t.view,a.moved);noevent(t);a.event(t).end()}}function dblclicked(t,...o){if(l.apply(this,arguments)){var e=this.__zoom,n=r(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(n),s=e.k*(t.shiftKey?.5:2),a=f(translate(scale(e,s),n,u),m.apply(this,o),z);noevent(t);y>0?i(this).transition().duration(y).call(schedule,a,n,t):i(this).call(zoom.transform,a,n,t)}}function touchstarted(t,...o){if(l.apply(this,arguments)){var e,n,i,s,c=t.touches,m=c.length,f=gesture(this,o,t.changedTouches.length===m).event(t);nopropagation(t);for(n=0;n>1;if(s>0&&typeof t[0]!=="number")throw new Error("Expected coords to contain numbers.");this.coords=t;const i=Math.max(2*s-5,0);this._triangles=new Uint32Array(i*3);this._halfedges=new Int32Array(i*3);this._hashSize=Math.ceil(Math.sqrt(s));this._hullPrev=new Uint32Array(s);this._hullNext=new Uint32Array(s);this._hullTri=new Uint32Array(s);this._hullHash=new Int32Array(this._hashSize);this._ids=new Uint32Array(s);this._dists=new Float64Array(s);this.update()}update(){const{coords:i,_hullPrev:n,_hullNext:e,_hullTri:h,_hullHash:l}=this;const r=i.length>>1;let o=Infinity;let c=Infinity;let a=-Infinity;let u=-Infinity;for(let t=0;ta&&(a=s);n>u&&(u=n);this._ids[t]=t}const _=(o+a)/2;const f=(c+u)/2;let d,y,g;for(let t=0,s=Infinity;t0){y=t;s=n}}let b=i[2*y];let p=i[2*y+1];let A=Infinity;for(let t=0;tn){t[s++]=e;n=h}}this.hull=t.subarray(0,s);this.triangles=new Uint32Array(0);this.halfedges=new Uint32Array(0);return}if(t(w,k,b,p,I,S)<0){const t=y;const s=b;const i=p;y=g;b=I;p=S;g=t;I=s;S=i}const m=circumcenter(w,k,b,p,I,S);this._cx=m.x;this._cy=m.y;for(let t=0;t0&&Math.abs(u-r)<=s&&Math.abs(_-o)<=s)continue;r=u;o=_;if(a===d||a===y||a===g)continue;let f=0;for(let t=0,s=this._hashKey(u,_);t=0){k=w;if(k===f){k=-1;break}}if(k===-1)continue;let b=this._addTriangle(k,a,e[k],-1,-1,h[k]);h[a]=this._legalize(b+2);h[k]=b;x++;let p=e[k];while(w=e[p],t(u,_,i[2*p],i[2*p+1],i[2*w],i[2*w+1])<0){b=this._addTriangle(p,a,w,h[a],-1,h[p]);h[a]=this._legalize(b+2);e[p]=p;x--;p=w}if(k===f)while(w=n[k],t(u,_,i[2*w],i[2*w+1],i[2*k],i[2*k+1])<0){b=this._addTriangle(w,a,k,-1,h[k],h[w]);this._legalize(b+2);h[w]=b;e[k]=k;x--;k=w}this._hullStart=n[a]=k;e[k]=n[p]=a;e[a]=p;l[this._hashKey(u,_)]=a;l[this._hashKey(i[2*k],i[2*k+1])]=k}this.hull=new Uint32Array(x);for(let t=0,s=this._hullStart;t0?3-i:1+i)/4}function dist(t,s,i,n){const e=t-i;const h=s-n;return e*e+h*h}function inCircle(t,s,i,n,e,h,l,r){const o=t-l;const c=s-r;const a=i-l;const u=n-r;const _=e-l;const f=h-r;const d=o*o+c*c;const y=a*a+u*u;const g=_*_+f*f;return o*(u*g-y*f)-c*(a*g-y*_)+d*(a*f-u*_)<0}function circumradius(t,s,i,n,e,h){const l=i-t;const r=n-s;const o=e-t;const c=h-s;const a=l*l+r*r;const u=o*o+c*c;const _=.5/(l*c-r*o);const f=(c*a-r*u)*_;const d=(l*u-o*a)*_;return f*f+d*d}function circumcenter(t,s,i,n,e,h){const l=i-t;const r=n-s;const o=e-t;const c=h-s;const a=l*l+r*r;const u=o*o+c*c;const _=.5/(l*c-r*o);const f=t+(c*a-r*u)*_;const d=s+(l*u-o*a)*_;return{x:f,y:d}}function quicksort(t,s,i,n){if(n-i<=20)for(let e=i+1;e<=n;e++){const n=t[e];const h=s[n];let l=e-1;while(l>=i&&s[t[l]]>h)t[l+1]=t[l--];t[l+1]=n}else{const e=i+n>>1;let h=i+1;let l=n;swap(t,e,h);s[t[i]]>s[t[n]]&&swap(t,i,n);s[t[h]]>s[t[n]]&&swap(t,h,n);s[t[i]]>s[t[h]]&&swap(t,i,h);const r=t[h];const o=s[r];while(true){do{h++}while(s[t[h]]o);if(l=l-i){quicksort(t,s,h,n);quicksort(t,s,i,l-1)}else{quicksort(t,s,i,l-1);quicksort(t,s,h,n)}}}function swap(t,s,i){const n=t[s];t[s]=t[i];t[i]=n}function defaultGetX(t){return t[0]}function defaultGetY(t){return t[1]}export{Delaunator as default}; diff --git a/vendor/javascript/internmap.js b/vendor/javascript/internmap.js index 8f8ace93..d1aea069 100644 --- a/vendor/javascript/internmap.js +++ b/vendor/javascript/internmap.js @@ -1,2 +1,4 @@ +// internmap@2.0.3 downloaded from https://ga.jspm.io/npm:internmap@2.0.3/src/index.js + class InternMap extends Map{constructor(e,t=keyof){super();Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}});if(null!=e)for(const[t,n]of e)this.set(t,n)}get(e){return super.get(intern_get(this,e))}has(e){return super.has(intern_get(this,e))}set(e,t){return super.set(intern_set(this,e),t)}delete(e){return super.delete(intern_delete(this,e))}}class InternSet extends Set{constructor(e,t=keyof){super();Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}});if(null!=e)for(const t of e)this.add(t)}has(e){return super.has(intern_get(this,e))}add(e){return super.add(intern_set(this,e))}delete(e){return super.delete(intern_delete(this,e))}}function intern_get({_intern:e,_key:t},n){const r=t(n);return e.has(r)?e.get(r):n}function intern_set({_intern:e,_key:t},n){const r=t(n);if(e.has(r))return e.get(r);e.set(r,n);return n}function intern_delete({_intern:e,_key:t},n){const r=t(n);if(e.has(r)){n=e.get(r);e.delete(r)}return n}function keyof(e){return null!==e&&"object"===typeof e?e.valueOf():e}export{InternMap,InternSet}; diff --git a/vendor/javascript/robust-predicates.js b/vendor/javascript/robust-predicates.js index d82a84a8..c89bb214 100644 --- a/vendor/javascript/robust-predicates.js +++ b/vendor/javascript/robust-predicates.js @@ -1,2 +1,4 @@ +// robust-predicates@3.0.2 downloaded from https://ga.jspm.io/npm:robust-predicates@3.0.2/index.js + const c=11102230246251565e-32;const s=134217729;const t=(3+8*c)*c;function sum(c,s,t,n,e){let o,a,l,i;let r=s[0];let f=n[0];let u=0;let d=0;if(f>r===f>-r){o=r;r=s[++u]}else{o=f;f=n[++d]}let v=0;if(ur===f>-r){a=r+o;l=o-(a-r);r=s[++u]}else{a=f+o;l=o-(a-f);f=n[++d]}o=a;0!==l&&(e[v++]=l);while(ur===f>-r){a=o+r;i=a-o;l=o-(a-i)+(r-i);r=s[++u]}else{a=o+f;i=a-o;l=o-(a-i)+(f-i);f=n[++d]}o=a;0!==l&&(e[v++]=l)}}while(u=K||-J>=K)return J;$=c-E;_=c-(E+$)+($-v);$=u-G;M=u-(G+$)+($-v);$=n-H;b=n-(H+$)+($-h);$=d-I;p=d-(I+$)+($-h);if(0===_&&0===b&&0===M&&0===p)return J;K=o*m+t*Math.abs(J);J+=E*p+I*_-(H*M+G*b);if(J>=K||-J>=K)return J;q=_*I;x=s*_;g=x-(x-_);w=_-g;x=s*I;y=x-(x-I);A=I-y;z=w*A-(q-g*y-w*y-g*A);B=b*G;x=s*b;g=x-(x-b);w=b-g;x=s*G;y=x-(x-G);A=G-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const L=sum(4,a,4,f,l);q=E*p;x=s*E;g=x-(x-E);w=E-g;x=s*p;y=x-(x-p);A=p-y;z=w*A-(q-g*y-w*y-g*A);B=H*M;x=s*H;g=x-(x-H);w=H-g;x=s*M;y=x-(x-M);A=M-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const N=sum(L,l,4,f,i);q=_*p;x=s*_;g=x-(x-_);w=_-g;x=s*p;y=x-(x-p);A=p-y;z=w*A-(q-g*y-w*y-g*A);B=b*M;x=s*b;g=x-(x-b);w=b-g;x=s*M;y=x-(x-M);A=M-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const O=sum(N,i,4,f,r);return r[O-1]}function orient2d(c,s,t,e,o,a){const l=(s-a)*(t-o);const i=(c-o)*(e-a);const r=l-i;const f=Math.abs(l+i);return Math.abs(r)>=n*f?r:-orient2dadapt(c,s,t,e,o,a,f)}function orient2dfast(c,s,t,n,e,o){return(s-o)*(t-e)-(c-e)*(n-o)}const u=(7+56*c)*c;const d=(3+28*c)*c;const v=(26+288*c)*c*c;const h=vec(4);const m=vec(4);const _=vec(4);const b=vec(4);const M=vec(4);const p=vec(4);const $=vec(4);const x=vec(4);const g=vec(4);const w=vec(8);const y=vec(8);const A=vec(8);const F=vec(4);const j=vec(8);const k=vec(8);const q=vec(8);const z=vec(12);let B=vec(192);let C=vec(192);function finadd$1(c,s,t){c=sum(c,B,s,t,C);const n=B;B=C;C=n;return c}function tailinit(c,t,n,e,o,a,l,i){let r,f,u,d,v,h,m,_,b,M,p,$,x,g,w;if(0===c){if(0===t){l[0]=0;i[0]=0;return 1}w=-t;M=w*n;f=s*w;u=f-(f-w);d=w-u;f=s*n;v=f-(f-n);h=n-v;l[0]=d*h-(M-u*v-d*v-u*h);l[1]=M;M=t*o;f=s*t;u=f-(f-t);d=t-u;f=s*o;v=f-(f-o);h=o-v;i[0]=d*h-(M-u*v-d*v-u*h);i[1]=M;return 2}if(0===t){M=c*e;f=s*c;u=f-(f-c);d=c-u;f=s*e;v=f-(f-e);h=e-v;l[0]=d*h-(M-u*v-d*v-u*h);l[1]=M;w=-c;M=w*a;f=s*w;u=f-(f-w);d=w-u;f=s*a;v=f-(f-a);h=a-v;i[0]=d*h-(M-u*v-d*v-u*h);i[1]=M;return 2}M=c*e;f=s*c;u=f-(f-c);d=c-u;f=s*e;v=f-(f-e);h=e-v;p=d*h-(M-u*v-d*v-u*h);$=t*n;f=s*t;u=f-(f-t);d=t-u;f=s*n;v=f-(f-n);h=n-v;x=d*h-($-u*v-d*v-u*h);m=p-x;r=p-m;l[0]=p-(m+r)+(r-x);_=M+m;r=_-M;b=M-(_-r)+(m-r);m=b-$;r=b-m;l[1]=b-(m+r)+(r-$);g=_+m;r=g-_;l[2]=_-(g-r)+(m-r);l[3]=g;M=t*o;f=s*t;u=f-(f-t);d=t-u;f=s*o;v=f-(f-o);h=o-v;p=d*h-(M-u*v-d*v-u*h);$=c*a;f=s*c;u=f-(f-c);d=c-u;f=s*a;v=f-(f-a);h=a-v;x=d*h-($-u*v-d*v-u*h);m=p-x;r=p-m;i[0]=p-(m+r)+(r-x);_=M+m;r=_-M;b=M-(_-r)+(m-r);m=b-$;r=b-m;i[1]=b-(m+r)+(r-$);g=_+m;r=g-_;i[2]=_-(g-r)+(m-r);i[3]=g;return 4}function tailadd(c,t,n,e,o){let a,l,i,r,f,u,d,v,h,m,_,b,M;_=t*n;l=s*t;i=l-(l-t);r=t-i;l=s*n;f=l-(l-n);u=n-f;b=r*u-(_-i*f-r*f-i*u);l=s*e;f=l-(l-e);u=e-f;d=b*e;l=s*b;i=l-(l-b);r=b-i;F[0]=r*u-(d-i*f-r*f-i*u);v=_*e;l=s*_;i=l-(l-_);r=_-i;m=r*u-(v-i*f-r*f-i*u);h=d+m;a=h-d;F[1]=d-(h-a)+(m-a);M=v+h;F[2]=h-(M-v);F[3]=M;c=finadd$1(c,4,F);if(0!==o){l=s*o;f=l-(l-o);u=o-f;d=b*o;l=s*b;i=l-(l-b);r=b-i;F[0]=r*u-(d-i*f-r*f-i*u);v=_*o;l=s*_;i=l-(l-_);r=_-i;m=r*u-(v-i*f-r*f-i*u);h=d+m;a=h-d;F[1]=d-(h-a)+(m-a);M=v+h;F[2]=h-(M-v);F[3]=M;c=finadd$1(c,4,F)}return c}function orient3dadapt(c,n,e,o,a,l,i,r,f,u,F,C,D){let E;let G,H,I;let J,K,L;let N,O,P;let Q,R,S,T,U,V,W,X,Y,Z,cc,sc,tc,nc;const ec=c-u;const oc=o-u;const ac=i-u;const lc=n-F;const ic=a-F;const rc=r-F;const fc=e-C;const uc=l-C;const dc=f-C;Z=oc*rc;R=s*oc;S=R-(R-oc);T=oc-S;R=s*rc;U=R-(R-rc);V=rc-U;cc=T*V-(Z-S*U-T*U-S*V);sc=ac*ic;R=s*ac;S=R-(R-ac);T=ac-S;R=s*ic;U=R-(R-ic);V=ic-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;h[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;h[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;h[2]=X-(nc-Q)+(W-Q);h[3]=nc;Z=ac*lc;R=s*ac;S=R-(R-ac);T=ac-S;R=s*lc;U=R-(R-lc);V=lc-U;cc=T*V-(Z-S*U-T*U-S*V);sc=ec*rc;R=s*ec;S=R-(R-ec);T=ec-S;R=s*rc;U=R-(R-rc);V=rc-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;m[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;m[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;m[2]=X-(nc-Q)+(W-Q);m[3]=nc;Z=ec*ic;R=s*ec;S=R-(R-ec);T=ec-S;R=s*ic;U=R-(R-ic);V=ic-U;cc=T*V-(Z-S*U-T*U-S*V);sc=oc*lc;R=s*oc;S=R-(R-oc);T=oc-S;R=s*lc;U=R-(R-lc);V=lc-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;_[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;_[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;_[2]=X-(nc-Q)+(W-Q);_[3]=nc;E=sum(sum(scale(4,h,fc,j),j,scale(4,m,uc,k),k,q),q,scale(4,_,dc,j),j,B);let vc=estimate(E,B);let hc=d*D;if(vc>=hc||-vc>=hc)return vc;Q=c-ec;G=c-(ec+Q)+(Q-u);Q=o-oc;H=o-(oc+Q)+(Q-u);Q=i-ac;I=i-(ac+Q)+(Q-u);Q=n-lc;J=n-(lc+Q)+(Q-F);Q=a-ic;K=a-(ic+Q)+(Q-F);Q=r-rc;L=r-(rc+Q)+(Q-F);Q=e-fc;N=e-(fc+Q)+(Q-C);Q=l-uc;O=l-(uc+Q)+(Q-C);Q=f-dc;P=f-(dc+Q)+(Q-C);if(0===G&&0===H&&0===I&&0===J&&0===K&&0===L&&0===N&&0===O&&0===P)return vc;hc=v*D+t*Math.abs(vc);vc+=fc*(oc*L+rc*H-(ic*I+ac*K))+N*(oc*rc-ic*ac)+uc*(ac*J+lc*I-(rc*G+ec*L))+O*(ac*lc-rc*ec)+dc*(ec*K+ic*G-(lc*H+oc*J))+P*(ec*ic-lc*oc);if(vc>=hc||-vc>=hc)return vc;const mc=tailinit(G,J,oc,ic,ac,rc,b,M);const _c=tailinit(H,K,ac,rc,ec,lc,p,$);const bc=tailinit(I,L,ec,lc,oc,ic,x,g);const Mc=sum(_c,p,bc,g,w);E=finadd$1(E,scale(Mc,w,fc,q),q);const pc=sum(bc,x,mc,M,y);E=finadd$1(E,scale(pc,y,uc,q),q);const $c=sum(mc,b,_c,$,A);E=finadd$1(E,scale($c,A,dc,q),q);if(0!==N){E=finadd$1(E,scale(4,h,N,z),z);E=finadd$1(E,scale(Mc,w,N,q),q)}if(0!==O){E=finadd$1(E,scale(4,m,O,z),z);E=finadd$1(E,scale(pc,y,O,q),q)}if(0!==P){E=finadd$1(E,scale(4,_,P,z),z);E=finadd$1(E,scale($c,A,P,q),q)}if(0!==G){0!==K&&(E=tailadd(E,G,K,dc,P));0!==L&&(E=tailadd(E,-G,L,uc,O))}if(0!==H){0!==L&&(E=tailadd(E,H,L,fc,N));0!==J&&(E=tailadd(E,-H,J,dc,P))}if(0!==I){0!==J&&(E=tailadd(E,I,J,uc,O));0!==K&&(E=tailadd(E,-I,K,fc,N))}return B[E-1]}function orient3d(c,s,t,n,e,o,a,l,i,r,f,d){const v=c-r;const h=n-r;const m=a-r;const _=s-f;const b=e-f;const M=l-f;const p=t-d;const $=o-d;const x=i-d;const g=h*M;const w=m*b;const y=m*_;const A=v*M;const F=v*b;const j=h*_;const k=p*(g-w)+$*(y-A)+x*(F-j);const q=(Math.abs(g)+Math.abs(w))*Math.abs(p)+(Math.abs(y)+Math.abs(A))*Math.abs($)+(Math.abs(F)+Math.abs(j))*Math.abs(x);const z=u*q;return k>z||-k>z?k:orient3dadapt(c,s,t,n,e,o,a,l,i,r,f,d,q)}function orient3dfast(c,s,t,n,e,o,a,l,i,r,f,u){const d=c-r;const v=n-r;const h=a-r;const m=s-f;const _=e-f;const b=l-f;const M=t-u;const p=o-u;const $=i-u;return d*(_*$-p*b)+v*(b*M-$*m)+h*(m*p-M*_)}const D=(10+96*c)*c;const E=(4+48*c)*c;const G=(44+576*c)*c*c;const H=vec(4);const I=vec(4);const J=vec(4);const K=vec(4);const L=vec(4);const N=vec(4);const O=vec(4);const P=vec(4);const Q=vec(8);const R=vec(8);const S=vec(8);const T=vec(8);const U=vec(8);const V=vec(8);const W=vec(8);const X=vec(8);const Y=vec(8);const Z=vec(4);const cc=vec(4);const sc=vec(4);const tc=vec(8);const nc=vec(16);const ec=vec(16);const oc=vec(16);const ac=vec(32);const lc=vec(32);const ic=vec(48);const rc=vec(64);let fc=vec(1152);let uc=vec(1152);function finadd(c,s,t){c=sum(c,fc,s,t,uc);const n=fc;fc=uc;uc=n;return c}function incircleadapt(c,n,e,o,a,l,i,r,f){let u;let d,v,h,m,_,b;let M,p,$,x,g,w;let y,A,F;let j,k,q;let z,B;let C,D,uc,dc,vc,hc,mc,_c,bc,Mc,pc,$c,xc,gc;const wc=c-i;const yc=e-i;const Ac=a-i;const Fc=n-r;const jc=o-r;const kc=l-r;Mc=yc*kc;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*jc;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;H[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;H[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;H[2]=_c-(gc-C)+(mc-C);H[3]=gc;Mc=Ac*Fc;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*kc;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;I[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;I[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;I[2]=_c-(gc-C)+(mc-C);I[3]=gc;Mc=wc*jc;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*Fc;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;J[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;J[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;J[2]=_c-(gc-C)+(mc-C);J[3]=gc;u=sum(sum(sum(scale(scale(4,H,wc,tc),tc,wc,nc),nc,scale(scale(4,H,Fc,tc),tc,Fc,ec),ec,ac),ac,sum(scale(scale(4,I,yc,tc),tc,yc,nc),nc,scale(scale(4,I,jc,tc),tc,jc,ec),ec,lc),lc,rc),rc,sum(scale(scale(4,J,Ac,tc),tc,Ac,nc),nc,scale(scale(4,J,kc,tc),tc,kc,ec),ec,ac),ac,fc);let qc=estimate(u,fc);let zc=E*f;if(qc>=zc||-qc>=zc)return qc;C=c-wc;d=c-(wc+C)+(C-i);C=n-Fc;m=n-(Fc+C)+(C-r);C=e-yc;v=e-(yc+C)+(C-i);C=o-jc;_=o-(jc+C)+(C-r);C=a-Ac;h=a-(Ac+C)+(C-i);C=l-kc;b=l-(kc+C)+(C-r);if(0===d&&0===v&&0===h&&0===m&&0===_&&0===b)return qc;zc=G*f+t*Math.abs(qc);qc+=(wc*wc+Fc*Fc)*(yc*b+kc*v-(jc*h+Ac*_))+2*(wc*d+Fc*m)*(yc*kc-jc*Ac)+((yc*yc+jc*jc)*(Ac*m+Fc*h-(kc*d+wc*b))+2*(yc*v+jc*_)*(Ac*Fc-kc*wc))+((Ac*Ac+kc*kc)*(wc*_+jc*d-(Fc*v+yc*m))+2*(Ac*h+kc*b)*(wc*jc-Fc*yc));if(qc>=zc||-qc>=zc)return qc;if(0!==v||0!==_||0!==h||0!==b){Mc=wc*wc;D=s*wc;uc=D-(D-wc);dc=wc-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=Fc*Fc;D=s*Fc;uc=D-(D-Fc);dc=Fc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;K[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;K[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;K[2]=_c-(gc-C)+(mc-C);K[3]=gc}if(0!==h||0!==b||0!==d||0!==m){Mc=yc*yc;D=s*yc;uc=D-(D-yc);dc=yc-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=jc*jc;D=s*jc;uc=D-(D-jc);dc=jc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;L[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;L[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;L[2]=_c-(gc-C)+(mc-C);L[3]=gc}if(0!==d||0!==m||0!==v||0!==_){Mc=Ac*Ac;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=kc*kc;D=s*kc;uc=D-(D-kc);dc=kc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;N[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;N[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;N[2]=_c-(gc-C)+(mc-C);N[3]=gc}if(0!==d){M=scale(4,H,d,Q);u=finadd(u,sum_three(scale(M,Q,2*wc,nc),nc,scale(scale(4,N,d,tc),tc,jc,ec),ec,scale(scale(4,L,d,tc),tc,-kc,oc),oc,ac,ic),ic)}if(0!==m){p=scale(4,H,m,R);u=finadd(u,sum_three(scale(p,R,2*Fc,nc),nc,scale(scale(4,L,m,tc),tc,Ac,ec),ec,scale(scale(4,N,m,tc),tc,-yc,oc),oc,ac,ic),ic)}if(0!==v){$=scale(4,I,v,S);u=finadd(u,sum_three(scale($,S,2*yc,nc),nc,scale(scale(4,K,v,tc),tc,kc,ec),ec,scale(scale(4,N,v,tc),tc,-Fc,oc),oc,ac,ic),ic)}if(0!==_){x=scale(4,I,_,T);u=finadd(u,sum_three(scale(x,T,2*jc,nc),nc,scale(scale(4,N,_,tc),tc,wc,ec),ec,scale(scale(4,K,_,tc),tc,-Ac,oc),oc,ac,ic),ic)}if(0!==h){g=scale(4,J,h,U);u=finadd(u,sum_three(scale(g,U,2*Ac,nc),nc,scale(scale(4,L,h,tc),tc,Fc,ec),ec,scale(scale(4,K,h,tc),tc,-jc,oc),oc,ac,ic),ic)}if(0!==b){w=scale(4,J,b,V);u=finadd(u,sum_three(scale(w,V,2*kc,nc),nc,scale(scale(4,K,b,tc),tc,yc,ec),ec,scale(scale(4,L,b,tc),tc,-wc,oc),oc,ac,ic),ic)}if(0!==d||0!==m){if(0!==v||0!==_||0!==h||0!==b){Mc=v*kc;D=s*v;uc=D-(D-v);dc=v-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*b;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*b;vc=D-(D-b);hc=b-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;Mc=h*-jc;D=s*h;uc=D-(D-h);dc=h-uc;D=s*-jc;vc=D-(D- -jc);hc=-jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*-_;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*-_;vc=D-(D- -_);hc=-_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;A=sum(4,O,4,P,X);Mc=v*b;D=s*v;uc=D-(D-v);dc=v-uc;D=s*b;vc=D-(D-b);hc=b-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=h*_;D=s*h;uc=D-(D-h);dc=h-uc;D=s*_;vc=D-(D-_);hc=_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;cc[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;cc[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;cc[2]=_c-(gc-C)+(mc-C);cc[3]=gc;k=4}else{X[0]=0;A=1;cc[0]=0;k=1}if(0!==d){const c=scale(A,X,d,oc);u=finadd(u,sum(scale(M,Q,d,nc),nc,scale(c,oc,2*wc,ac),ac,ic),ic);const s=scale(k,cc,d,tc);u=finadd(u,sum_three(scale(s,tc,2*wc,nc),nc,scale(s,tc,d,ec),ec,scale(c,oc,d,ac),ac,lc,rc),rc);0!==_&&(u=finadd(u,scale(scale(4,N,d,tc),tc,_,nc),nc));0!==b&&(u=finadd(u,scale(scale(4,L,-d,tc),tc,b,nc),nc))}if(0!==m){const c=scale(A,X,m,oc);u=finadd(u,sum(scale(p,R,m,nc),nc,scale(c,oc,2*Fc,ac),ac,ic),ic);const s=scale(k,cc,m,tc);u=finadd(u,sum_three(scale(s,tc,2*Fc,nc),nc,scale(s,tc,m,ec),ec,scale(c,oc,m,ac),ac,lc,rc),rc)}}if(0!==v||0!==_){if(0!==h||0!==b||0!==d||0!==m){Mc=h*Fc;D=s*h;uc=D-(D-h);dc=h-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*m;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*m;vc=D-(D-m);hc=m-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;z=-kc;B=-b;Mc=d*z;D=s*d;uc=D-(D-d);dc=d-uc;D=s*z;vc=D-(D-z);hc=z-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*B;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*B;vc=D-(D-B);hc=B-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;F=sum(4,O,4,P,Y);Mc=h*m;D=s*h;uc=D-(D-h);dc=h-uc;D=s*m;vc=D-(D-m);hc=m-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=d*b;D=s*d;uc=D-(D-d);dc=d-uc;D=s*b;vc=D-(D-b);hc=b-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;sc[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;sc[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;sc[2]=_c-(gc-C)+(mc-C);sc[3]=gc;q=4}else{Y[0]=0;F=1;sc[0]=0;q=1}if(0!==v){const c=scale(F,Y,v,oc);u=finadd(u,sum(scale($,S,v,nc),nc,scale(c,oc,2*yc,ac),ac,ic),ic);const s=scale(q,sc,v,tc);u=finadd(u,sum_three(scale(s,tc,2*yc,nc),nc,scale(s,tc,v,ec),ec,scale(c,oc,v,ac),ac,lc,rc),rc);0!==b&&(u=finadd(u,scale(scale(4,K,v,tc),tc,b,nc),nc));0!==m&&(u=finadd(u,scale(scale(4,N,-v,tc),tc,m,nc),nc))}if(0!==_){const c=scale(F,Y,_,oc);u=finadd(u,sum(scale(x,T,_,nc),nc,scale(c,oc,2*jc,ac),ac,ic),ic);const s=scale(q,sc,_,tc);u=finadd(u,sum_three(scale(s,tc,2*jc,nc),nc,scale(s,tc,_,ec),ec,scale(c,oc,_,ac),ac,lc,rc),rc)}}if(0!==h||0!==b){if(0!==d||0!==m||0!==v||0!==_){Mc=d*jc;D=s*d;uc=D-(D-d);dc=d-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*_;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*_;vc=D-(D-_);hc=_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;z=-Fc;B=-m;Mc=v*z;D=s*v;uc=D-(D-v);dc=v-uc;D=s*z;vc=D-(D-z);hc=z-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*B;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*B;vc=D-(D-B);hc=B-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;y=sum(4,O,4,P,W);Mc=d*_;D=s*d;uc=D-(D-d);dc=d-uc;D=s*_;vc=D-(D-_);hc=_-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=v*m;D=s*v;uc=D-(D-v);dc=v-uc;D=s*m;vc=D-(D-m);hc=m-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;Z[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;Z[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;Z[2]=_c-(gc-C)+(mc-C);Z[3]=gc;j=4}else{W[0]=0;y=1;Z[0]=0;j=1}if(0!==h){const c=scale(y,W,h,oc);u=finadd(u,sum(scale(g,U,h,nc),nc,scale(c,oc,2*Ac,ac),ac,ic),ic);const s=scale(j,Z,h,tc);u=finadd(u,sum_three(scale(s,tc,2*Ac,nc),nc,scale(s,tc,h,ec),ec,scale(c,oc,h,ac),ac,lc,rc),rc);0!==m&&(u=finadd(u,scale(scale(4,L,h,tc),tc,m,nc),nc));0!==_&&(u=finadd(u,scale(scale(4,K,-h,tc),tc,_,nc),nc))}if(0!==b){const c=scale(y,W,b,oc);u=finadd(u,sum(scale(w,V,b,nc),nc,scale(c,oc,2*kc,ac),ac,ic),ic);const s=scale(j,Z,b,tc);u=finadd(u,sum_three(scale(s,tc,2*kc,nc),nc,scale(s,tc,b,ec),ec,scale(c,oc,b,ac),ac,lc,rc),rc)}}return fc[u-1]}function incircle(c,s,t,n,e,o,a,l){const i=c-a;const r=t-a;const f=e-a;const u=s-l;const d=n-l;const v=o-l;const h=r*v;const m=f*d;const _=i*i+u*u;const b=f*u;const M=i*v;const p=r*r+d*d;const $=i*d;const x=r*u;const g=f*f+v*v;const w=_*(h-m)+p*(b-M)+g*($-x);const y=(Math.abs(h)+Math.abs(m))*_+(Math.abs(b)+Math.abs(M))*p+(Math.abs($)+Math.abs(x))*g;const A=D*y;return w>A||-w>A?w:incircleadapt(c,s,t,n,e,o,a,l,y)}function incirclefast(c,s,t,n,e,o,a,l){const i=c-a;const r=s-l;const f=t-a;const u=n-l;const d=e-a;const v=o-l;const h=i*u-f*r;const m=f*v-d*u;const _=d*r-i*v;const b=i*i+r*r;const M=f*f+u*u;const p=d*d+v*v;return b*m+M*_+p*h}const dc=(16+224*c)*c;const vc=(5+72*c)*c;const hc=(71+1408*c)*c*c;const mc=vec(4);const _c=vec(4);const bc=vec(4);const Mc=vec(4);const pc=vec(4);const $c=vec(4);const xc=vec(4);const gc=vec(4);const wc=vec(4);const yc=vec(4);const Ac=vec(24);const Fc=vec(24);const jc=vec(24);const kc=vec(24);const qc=vec(24);const zc=vec(24);const Bc=vec(24);const Cc=vec(24);const Dc=vec(24);const Ec=vec(24);const Gc=vec(1152);const Hc=vec(1152);const Ic=vec(1152);const Jc=vec(1152);const Kc=vec(1152);const Lc=vec(2304);const Nc=vec(2304);const Oc=vec(3456);const Pc=vec(5760);const Qc=vec(8);const Rc=vec(8);const Sc=vec(8);const Tc=vec(16);const Uc=vec(24);const Vc=vec(48);const Wc=vec(48);const Xc=vec(96);const Yc=vec(192);const Zc=vec(384);const cs=vec(384);const ss=vec(384);const ts=vec(768);function sum_three_scale(c,s,t,n,e,o,a){return sum_three(scale(4,c,n,Qc),Qc,scale(4,s,e,Rc),Rc,scale(4,t,o,Sc),Sc,Tc,a)}function liftexact(c,s,t,n,e,o,a,l,i,r,f,u){const d=sum(sum(c,s,t,n,Vc),Vc,negate(sum(e,o,a,l,Wc),Wc),Wc,Xc);return sum_three(scale(scale(d,Xc,i,Yc),Yc,i,Zc),Zc,scale(scale(d,Xc,r,Yc),Yc,r,cs),cs,scale(scale(d,Xc,f,Yc),Yc,f,ss),ss,ts,u)}function insphereexact(c,t,n,e,o,a,l,i,r,f,u,d,v,h,m){let _,b,M,p,$,x,g,w,y,A,F,j,k,q;A=c*o;b=s*c;M=b-(b-c);p=c-M;b=s*o;$=b-(b-o);x=o-$;F=p*x-(A-M*$-p*$-M*x);j=e*t;b=s*e;M=b-(b-e);p=e-M;b=s*t;$=b-(b-t);x=t-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;mc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;mc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;mc[2]=w-(q-_)+(g-_);mc[3]=q;A=e*i;b=s*e;M=b-(b-e);p=e-M;b=s*i;$=b-(b-i);x=i-$;F=p*x-(A-M*$-p*$-M*x);j=l*o;b=s*l;M=b-(b-l);p=l-M;b=s*o;$=b-(b-o);x=o-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;_c[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;_c[1]=y-(g+_)+(_-j);q=w+g;_=q-w;_c[2]=w-(q-_)+(g-_);_c[3]=q;A=l*u;b=s*l;M=b-(b-l);p=l-M;b=s*u;$=b-(b-u);x=u-$;F=p*x-(A-M*$-p*$-M*x);j=f*i;b=s*f;M=b-(b-f);p=f-M;b=s*i;$=b-(b-i);x=i-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;bc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;bc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;bc[2]=w-(q-_)+(g-_);bc[3]=q;A=f*h;b=s*f;M=b-(b-f);p=f-M;b=s*h;$=b-(b-h);x=h-$;F=p*x-(A-M*$-p*$-M*x);j=v*u;b=s*v;M=b-(b-v);p=v-M;b=s*u;$=b-(b-u);x=u-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;Mc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;Mc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;Mc[2]=w-(q-_)+(g-_);Mc[3]=q;A=v*t;b=s*v;M=b-(b-v);p=v-M;b=s*t;$=b-(b-t);x=t-$;F=p*x-(A-M*$-p*$-M*x);j=c*h;b=s*c;M=b-(b-c);p=c-M;b=s*h;$=b-(b-h);x=h-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;pc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;pc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;pc[2]=w-(q-_)+(g-_);pc[3]=q;A=c*i;b=s*c;M=b-(b-c);p=c-M;b=s*i;$=b-(b-i);x=i-$;F=p*x-(A-M*$-p*$-M*x);j=l*t;b=s*l;M=b-(b-l);p=l-M;b=s*t;$=b-(b-t);x=t-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;$c[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;$c[1]=y-(g+_)+(_-j);q=w+g;_=q-w;$c[2]=w-(q-_)+(g-_);$c[3]=q;A=e*u;b=s*e;M=b-(b-e);p=e-M;b=s*u;$=b-(b-u);x=u-$;F=p*x-(A-M*$-p*$-M*x);j=f*o;b=s*f;M=b-(b-f);p=f-M;b=s*o;$=b-(b-o);x=o-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;xc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;xc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;xc[2]=w-(q-_)+(g-_);xc[3]=q;A=l*h;b=s*l;M=b-(b-l);p=l-M;b=s*h;$=b-(b-h);x=h-$;F=p*x-(A-M*$-p*$-M*x);j=v*i;b=s*v;M=b-(b-v);p=v-M;b=s*i;$=b-(b-i);x=i-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;gc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;gc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;gc[2]=w-(q-_)+(g-_);gc[3]=q;A=f*t;b=s*f;M=b-(b-f);p=f-M;b=s*t;$=b-(b-t);x=t-$;F=p*x-(A-M*$-p*$-M*x);j=c*u;b=s*c;M=b-(b-c);p=c-M;b=s*u;$=b-(b-u);x=u-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;wc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;wc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;wc[2]=w-(q-_)+(g-_);wc[3]=q;A=v*o;b=s*v;M=b-(b-v);p=v-M;b=s*o;$=b-(b-o);x=o-$;F=p*x-(A-M*$-p*$-M*x);j=e*h;b=s*e;M=b-(b-e);p=e-M;b=s*h;$=b-(b-h);x=h-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;yc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;yc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;yc[2]=w-(q-_)+(g-_);yc[3]=q;const z=sum_three_scale(mc,_c,$c,r,n,-a,Ac);const B=sum_three_scale(_c,bc,xc,d,a,-r,Fc);const C=sum_three_scale(bc,Mc,gc,m,r,-d,jc);const D=sum_three_scale(Mc,pc,wc,n,d,-m,kc);const E=sum_three_scale(pc,mc,yc,a,m,-n,qc);const G=sum_three_scale(mc,xc,wc,d,n,a,zc);const H=sum_three_scale(_c,gc,yc,m,a,r,Bc);const I=sum_three_scale(bc,wc,$c,n,r,d,Cc);const J=sum_three_scale(Mc,yc,xc,a,d,m,Dc);const K=sum_three_scale(pc,$c,gc,r,m,n,Ec);const L=sum_three(liftexact(C,jc,H,Bc,J,Dc,B,Fc,c,t,n,Gc),Gc,liftexact(D,kc,I,Cc,K,Ec,C,jc,e,o,a,Hc),Hc,sum_three(liftexact(E,qc,J,Dc,G,zc,D,kc,l,i,r,Ic),Ic,liftexact(z,Ac,K,Ec,H,Bc,E,qc,f,u,d,Jc),Jc,liftexact(B,Fc,G,zc,I,Cc,z,Ac,v,h,m,Kc),Kc,Nc,Oc),Oc,Lc,Pc);return Pc[L-1]}const ns=vec(96);const es=vec(96);const os=vec(96);const as=vec(1152);function liftadapt(c,s,t,n,e,o,a,l,i,r){const f=sum_three_scale(c,s,t,n,e,o,Uc);return sum_three(scale(scale(f,Uc,a,Vc),Vc,a,ns),ns,scale(scale(f,Uc,l,Vc),Vc,l,es),es,scale(scale(f,Uc,i,Vc),Vc,i,os),os,Yc,r)}function insphereadapt(c,n,e,o,a,l,i,r,f,u,d,v,h,m,_,b){let M,p,$,x,g,w;let y,A,F,j;let k,q,z,B;let C,D,E,G;let H,I,J,K,L,N,O,P,Q,R,S,T,U;const V=c-h;const W=o-h;const X=i-h;const Y=u-h;const Z=n-m;const cc=a-m;const sc=r-m;const tc=d-m;const nc=e-_;const ec=l-_;const oc=f-_;const ac=v-_;R=V*cc;I=s*V;J=I-(I-V);K=V-J;I=s*cc;L=I-(I-cc);N=cc-L;S=K*N-(R-J*L-K*L-J*N);T=W*Z;I=s*W;J=I-(I-W);K=W-J;I=s*Z;L=I-(I-Z);N=Z-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;mc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;mc[1]=Q-(O+H)+(H-T);M=P+O;H=M-P;mc[2]=P-(M-H)+(O-H);mc[3]=M;R=W*sc;I=s*W;J=I-(I-W);K=W-J;I=s*sc;L=I-(I-sc);N=sc-L;S=K*N-(R-J*L-K*L-J*N);T=X*cc;I=s*X;J=I-(I-X);K=X-J;I=s*cc;L=I-(I-cc);N=cc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;_c[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;_c[1]=Q-(O+H)+(H-T);p=P+O;H=p-P;_c[2]=P-(p-H)+(O-H);_c[3]=p;R=X*tc;I=s*X;J=I-(I-X);K=X-J;I=s*tc;L=I-(I-tc);N=tc-L;S=K*N-(R-J*L-K*L-J*N);T=Y*sc;I=s*Y;J=I-(I-Y);K=Y-J;I=s*sc;L=I-(I-sc);N=sc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;bc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;bc[1]=Q-(O+H)+(H-T);$=P+O;H=$-P;bc[2]=P-($-H)+(O-H);bc[3]=$;R=Y*Z;I=s*Y;J=I-(I-Y);K=Y-J;I=s*Z;L=I-(I-Z);N=Z-L;S=K*N-(R-J*L-K*L-J*N);T=V*tc;I=s*V;J=I-(I-V);K=V-J;I=s*tc;L=I-(I-tc);N=tc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;wc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;wc[1]=Q-(O+H)+(H-T);x=P+O;H=x-P;wc[2]=P-(x-H)+(O-H);wc[3]=x;R=V*sc;I=s*V;J=I-(I-V);K=V-J;I=s*sc;L=I-(I-sc);N=sc-L;S=K*N-(R-J*L-K*L-J*N);T=X*Z;I=s*X;J=I-(I-X);K=X-J;I=s*Z;L=I-(I-Z);N=Z-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;$c[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;$c[1]=Q-(O+H)+(H-T);g=P+O;H=g-P;$c[2]=P-(g-H)+(O-H);$c[3]=g;R=W*tc;I=s*W;J=I-(I-W);K=W-J;I=s*tc;L=I-(I-tc);N=tc-L;S=K*N-(R-J*L-K*L-J*N);T=Y*cc;I=s*Y;J=I-(I-Y);K=Y-J;I=s*cc;L=I-(I-cc);N=cc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;xc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;xc[1]=Q-(O+H)+(H-T);w=P+O;H=w-P;xc[2]=P-(w-H)+(O-H);xc[3]=w;const lc=sum(sum(negate(liftadapt(_c,bc,xc,ac,ec,-oc,V,Z,nc,Gc),Gc),Gc,liftadapt(bc,wc,$c,nc,oc,ac,W,cc,ec,Hc),Hc,Lc),Lc,sum(negate(liftadapt(wc,mc,xc,ec,ac,nc,X,sc,oc,Ic),Ic),Ic,liftadapt(mc,_c,$c,oc,nc,-ec,Y,tc,ac,Jc),Jc,Nc),Nc,as);let ic=estimate(lc,as);let rc=vc*b;if(ic>=rc||-ic>=rc)return ic;H=c-V;y=c-(V+H)+(H-h);H=n-Z;k=n-(Z+H)+(H-m);H=e-nc;C=e-(nc+H)+(H-_);H=o-W;A=o-(W+H)+(H-h);H=a-cc;q=a-(cc+H)+(H-m);H=l-ec;D=l-(ec+H)+(H-_);H=i-X;F=i-(X+H)+(H-h);H=r-sc;z=r-(sc+H)+(H-m);H=f-oc;E=f-(oc+H)+(H-_);H=u-Y;j=u-(Y+H)+(H-h);H=d-tc;B=d-(tc+H)+(H-m);H=v-ac;G=v-(ac+H)+(H-_);if(0===y&&0===k&&0===C&&0===A&&0===q&&0===D&&0===F&&0===z&&0===E&&0===j&&0===B&&0===G)return ic;rc=hc*b+t*Math.abs(ic);const fc=V*q+cc*y-(Z*A+W*k);const uc=W*z+sc*A-(cc*F+X*q);const dc=X*B+tc*F-(sc*j+Y*z);const Mc=Y*k+Z*j-(tc*y+V*B);const pc=V*z+sc*y-(Z*F+X*k);const gc=W*B+tc*A-(cc*j+Y*q);ic+=(W*W+cc*cc+ec*ec)*(oc*Mc+ac*pc+nc*dc+(E*x+G*g+C*$))+(Y*Y+tc*tc+ac*ac)*(nc*uc-ec*pc+oc*fc+(C*p-D*g+E*M))-((V*V+Z*Z+nc*nc)*(ec*dc-oc*gc+ac*uc+(D*$-E*w+G*p))+(X*X+sc*sc+oc*oc)*(ac*fc+nc*gc+ec*Mc+(G*M+C*w+D*x)))+2*((W*A+cc*q+ec*D)*(oc*x+ac*g+nc*$)+(Y*j+tc*B+ac*G)*(nc*p-ec*g+oc*M)-((V*y+Z*k+nc*C)*(ec*$-oc*w+ac*p)+(X*F+sc*z+oc*E)*(ac*M+nc*w+ec*x)));return ic>=rc||-ic>=rc?ic:insphereexact(c,n,e,o,a,l,i,r,f,u,d,v,h,m,_)}function insphere(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h){const m=c-d;const _=n-d;const b=a-d;const M=r-d;const p=s-v;const $=e-v;const x=l-v;const g=f-v;const w=t-h;const y=o-h;const A=i-h;const F=u-h;const j=m*$;const k=_*p;const q=j-k;const z=_*x;const B=b*$;const C=z-B;const D=b*g;const E=M*x;const G=D-E;const H=M*p;const I=m*g;const J=H-I;const K=m*x;const L=b*p;const N=K-L;const O=_*g;const P=M*$;const Q=O-P;const R=m*m+p*p+w*w;const S=_*_+$*$+y*y;const T=b*b+x*x+A*A;const U=M*M+g*g+F*F;const V=T*(F*q+w*Q+y*J)-U*(w*C-y*N+A*q)+(R*(y*G-A*Q+F*C)-S*(A*J+F*N+w*G));const W=Math.abs(w);const X=Math.abs(y);const Y=Math.abs(A);const Z=Math.abs(F);const cc=Math.abs(j)+Math.abs(k);const sc=Math.abs(z)+Math.abs(B);const tc=Math.abs(D)+Math.abs(E);const nc=Math.abs(H)+Math.abs(I);const ec=Math.abs(K)+Math.abs(L);const oc=Math.abs(O)+Math.abs(P);const ac=(tc*X+oc*Y+sc*Z)*R+(nc*Y+ec*Z+tc*W)*S+(cc*Z+oc*W+nc*X)*T+(sc*W+ec*X+cc*Y)*U;const lc=dc*ac;return V>lc||-V>lc?V:-insphereadapt(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h,ac)}function inspherefast(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h){const m=c-d;const _=n-d;const b=a-d;const M=r-d;const p=s-v;const $=e-v;const x=l-v;const g=f-v;const w=t-h;const y=o-h;const A=i-h;const F=u-h;const j=m*$-_*p;const k=_*x-b*$;const q=b*g-M*x;const z=M*p-m*g;const B=m*x-b*p;const C=_*g-M*$;const D=w*k-y*B+A*j;const E=y*q-A*C+F*k;const G=A*z+F*B+w*q;const H=F*j+w*C+y*z;const I=m*m+p*p+w*w;const J=_*_+$*$+y*y;const K=b*b+x*x+A*A;const L=M*M+g*g+F*F;return K*H-L*D+(I*E-J*G)}export{incircle,incirclefast,insphere,inspherefast,orient2d,orient2dfast,orient3d,orient3dfast};