From 886d41752a218fbcab36c781d7631e5eb150db91 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sat, 4 Apr 2020 19:45:36 +0200 Subject: [PATCH 01/27] feat(xpense): service skeleton, UI stub --- common/components/src/auto-complete.tsx | 58 ++++++++++ common/components/src/fab.tsx | 28 +++++ common/frontend-utils/src/apis/xpense-api.ts | 15 +++ common/models/src/apis/xpense-api.ts | 31 ++++++ common/models/src/xpense/account.ts | 16 +++ common/models/src/xpense/index.ts | 5 + common/models/src/xpense/item.ts | 8 ++ common/models/src/xpense/replenishment.ts | 7 ++ common/models/src/xpense/shop.ts | 6 ++ common/models/src/xpense/shopping.ts | 8 ++ .../pages/organizations/add-organization.tsx | 49 +++++++++ .../wrap-r/src/pages/xpense/add-account.tsx | 38 +++++++ frontends/wrap-r/src/pages/xpense/index.tsx | 41 +++++++ services/xpense/package.json | 27 +++++ services/xpense/src/actions/get-account.ts | 18 ++++ .../src/actions/get-available-accounts.ts | 10 ++ services/xpense/src/actions/get-items.ts | 10 ++ services/xpense/src/actions/get-shops.ts | 10 ++ services/xpense/src/actions/index.ts | 9 ++ services/xpense/src/actions/post-account.ts | 21 ++++ services/xpense/src/actions/post-item.ts | 19 ++++ .../xpense/src/actions/post-replenishment.ts | 43 ++++++++ services/xpense/src/actions/post-shop.ts | 19 ++++ services/xpense/src/actions/post-shopping.ts | 46 ++++++++ services/xpense/src/config.ts | 101 ++++++++++++++++++ services/xpense/src/service.ts | 41 +++++++ services/xpense/tsconfig.json | 11 ++ 27 files changed, 695 insertions(+) create mode 100644 common/components/src/auto-complete.tsx create mode 100644 common/components/src/fab.tsx create mode 100644 common/frontend-utils/src/apis/xpense-api.ts create mode 100644 common/models/src/apis/xpense-api.ts create mode 100644 common/models/src/xpense/account.ts create mode 100644 common/models/src/xpense/index.ts create mode 100644 common/models/src/xpense/item.ts create mode 100644 common/models/src/xpense/replenishment.ts create mode 100644 common/models/src/xpense/shop.ts create mode 100644 common/models/src/xpense/shopping.ts create mode 100644 frontends/wrap-r/src/pages/organizations/add-organization.tsx create mode 100644 frontends/wrap-r/src/pages/xpense/add-account.tsx create mode 100644 frontends/wrap-r/src/pages/xpense/index.tsx create mode 100644 services/xpense/package.json create mode 100644 services/xpense/src/actions/get-account.ts create mode 100644 services/xpense/src/actions/get-available-accounts.ts create mode 100644 services/xpense/src/actions/get-items.ts create mode 100644 services/xpense/src/actions/get-shops.ts create mode 100644 services/xpense/src/actions/index.ts create mode 100644 services/xpense/src/actions/post-account.ts create mode 100644 services/xpense/src/actions/post-item.ts create mode 100644 services/xpense/src/actions/post-replenishment.ts create mode 100644 services/xpense/src/actions/post-shop.ts create mode 100644 services/xpense/src/actions/post-shopping.ts create mode 100644 services/xpense/src/config.ts create mode 100644 services/xpense/src/service.ts create mode 100644 services/xpense/tsconfig.json diff --git a/common/components/src/auto-complete.tsx b/common/components/src/auto-complete.tsx new file mode 100644 index 00000000..9fd03942 --- /dev/null +++ b/common/components/src/auto-complete.tsx @@ -0,0 +1,58 @@ +import { Shade, createComponent, ChildrenList } from '@furystack/shades' +import { debounce, ObservableValue } from '@furystack/utils' + +export interface AutoCompleteProps { + style?: CSSStyleDeclaration + debounce?: number + value?: string + getSuggestions: (term: string) => Promise + renderSuggestion: (item: T) => JSX.Element +} + +export interface AutoCompleteState { + isOpened: ObservableValue + suggestions: ObservableValue + value?: T + fetchFn: (term: string) => Promise +} + +export const AutoCompleteSuggestionList = Shade<{ suggestions: ObservableValue }>({ + render: () => { + return
+ }, +}) + +export const AutoComplete: (props: AutoCompleteProps, children: ChildrenList) => JSX.Element = Shade< + AutoCompleteProps, + AutoCompleteState +>({ + shadowDomName: 'shade-auto-complete', + getInitialState: ({ props }) => ({ + isOpened: new ObservableValue(false), + value: props.value, + suggestions: new ObservableValue([]), + fetchFn: debounce(props.getSuggestions, props.debounce || 500), + }), + render: ({ props, updateState, getState }) => { + const { isOpened, suggestions, fetchFn } = getState() + return ( +
+ isOpened.setValue(true)} + value={getState().value} + onblur={() => { + getState().isOpened.setValue(false) + }} + onkeyup={async (ev) => { + const value = (ev.target as any).value as string + updateState({ value }, true) + const s = await fetchFn(value) + suggestions.setValue(s) + }} + /> + +
+ ) + }, +}) diff --git a/common/components/src/fab.tsx b/common/components/src/fab.tsx new file mode 100644 index 00000000..aa9117aa --- /dev/null +++ b/common/components/src/fab.tsx @@ -0,0 +1,28 @@ +import { Shade, PartialElement, createComponent } from '@furystack/shades' + +export const Fab = Shade>({ + shadowDomName: 'shade-fab', + render: ({ props, children }) => { + return ( +
+ {children} +
+ ) + }, +}) diff --git a/common/frontend-utils/src/apis/xpense-api.ts b/common/frontend-utils/src/apis/xpense-api.ts new file mode 100644 index 00000000..eb4632d7 --- /dev/null +++ b/common/frontend-utils/src/apis/xpense-api.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@furystack/inject' +import { PathHelper } from '@furystack/utils' +import { apis } from 'common-models' +import { createClient } from '@furystack/rest-client-fetch' +import { sites } from 'common-config' + +@Injectable({ lifetime: 'singleton' }) +export class XpenseApiService { + public call = createClient({ + endpointUrl: PathHelper.joinPaths(sites.services.xpense.externalPath, '/xpense'), + requestInit: { + credentials: 'include', + }, + }) +} diff --git a/common/models/src/apis/xpense-api.ts b/common/models/src/apis/xpense-api.ts new file mode 100644 index 00000000..35fe1632 --- /dev/null +++ b/common/models/src/apis/xpense-api.ts @@ -0,0 +1,31 @@ +import { RestApi, RequestAction } from '@furystack/rest' +import { Account, Shop, Item, Replenishment, Shopping } from '../xpense' + +export interface XpenseApi extends RestApi { + GET: { + '/shops': RequestAction<{ result: Shop[] }> + '/items': RequestAction<{ result: Item[] }> + '/availableAccounts': RequestAction<{ + result: Array<{ name: string; ownerType: Account['ownerType']; ownerName: Account['ownerName'] }> + }> + '/:type/:owner/:accountName': RequestAction<{ + result: Account + query: { type: 'user' | 'organization'; owner: string; accountName: string } + }> + } + POST: { + '/accounts': RequestAction<{ result: Account; body: { name: string } }> + '/shops': RequestAction<{ result: Shop; body: { name: string } }> + '/items': RequestAction<{ result: Item; body: { name: string; description: string; category?: string } }> + '/:type/:owner/:accountName/replenish': RequestAction<{ + body: { amount: number; comment?: string } + query: { type: 'user' | 'organization'; owner: string; accountName: string } + result: Replenishment + }> + '/:type/:owner/:accountName/shop': RequestAction<{ + body: Array<{ itemName: string; amount: number; unitPrice: number }> + query: { type: 'user' | 'organization'; owner: string; accountName: string } + result: Shopping + }> + } +} diff --git a/common/models/src/xpense/account.ts b/common/models/src/xpense/account.ts new file mode 100644 index 00000000..81025164 --- /dev/null +++ b/common/models/src/xpense/account.ts @@ -0,0 +1,16 @@ +export class Account { + _id!: string + ownerType!: 'user' | 'organization' + ownerName!: string + name!: string + history!: Array<{ + balance: number + date: string + change: number + changePerCategory: Array<{ categoryName: string; amount: number }> + relatedEntry: { replenishmentId: string } | { shoppingId: string } + }> + current!: number + createdBy!: string + creationDate!: string +} diff --git a/common/models/src/xpense/index.ts b/common/models/src/xpense/index.ts new file mode 100644 index 00000000..b522e650 --- /dev/null +++ b/common/models/src/xpense/index.ts @@ -0,0 +1,5 @@ +export * from './account' +export * from './item' +export * from './replenishment' +export * from './shop' +export * from './shopping' diff --git a/common/models/src/xpense/item.ts b/common/models/src/xpense/item.ts new file mode 100644 index 00000000..dfbdcc44 --- /dev/null +++ b/common/models/src/xpense/item.ts @@ -0,0 +1,8 @@ +export class Item { + _id!: string + name!: string + description?: string + category?: string + creationDate!: string + createdBy!: string +} diff --git a/common/models/src/xpense/replenishment.ts b/common/models/src/xpense/replenishment.ts new file mode 100644 index 00000000..fedda42c --- /dev/null +++ b/common/models/src/xpense/replenishment.ts @@ -0,0 +1,7 @@ +export class Replenishment { + _id!: string + createdBy!: string + accountId!: string + amount!: number + comment?: string +} diff --git a/common/models/src/xpense/shop.ts b/common/models/src/xpense/shop.ts new file mode 100644 index 00000000..bb1c0629 --- /dev/null +++ b/common/models/src/xpense/shop.ts @@ -0,0 +1,6 @@ +export class Shop { + _id!: string + name!: string + creationDate!: string + createdBy!: string +} diff --git a/common/models/src/xpense/shopping.ts b/common/models/src/xpense/shopping.ts new file mode 100644 index 00000000..8784a26b --- /dev/null +++ b/common/models/src/xpense/shopping.ts @@ -0,0 +1,8 @@ +export class Shopping { + _id!: string + accountId!: string + creationDate!: string + entries!: Array<{ itemName: string; amount: number; unitPrice: number }> + sumAmount!: number + createdBy!: string +} diff --git a/frontends/wrap-r/src/pages/organizations/add-organization.tsx b/frontends/wrap-r/src/pages/organizations/add-organization.tsx new file mode 100644 index 00000000..acb07e81 --- /dev/null +++ b/frontends/wrap-r/src/pages/organizations/add-organization.tsx @@ -0,0 +1,49 @@ +import { Shade, createComponent, LocationService } from '@furystack/shades' +import { styles, Input, Button, colors } from 'common-components' +import { Organization } from 'common-models' +import { WrapRApiService } from 'common-frontend-utils' + +export const AddOrganizationPage = Shade<{}, Omit>({ + getInitialState: () => ({ adminNames: [], memberNames: [], description: '', name: '', ownerName: '' }), + shadowDomName: 'shade-add-organization-page', + render: ({ getState, injector, updateState }) => { + return ( +
+
{ + ev.preventDefault() + const created = await injector.getInstance(WrapRApiService).call({ + method: 'POST', + action: '/organizations', + body: getState(), + }) + history.pushState({}, '', `/organization/${encodeURIComponent(created.name)}`) + }}> + updateState({ name: (ev.target as any).value }, true)} + /> + updateState({ description: (ev.target as any).value }, true)} + /> + + +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/add-account.tsx b/frontends/wrap-r/src/pages/xpense/add-account.tsx new file mode 100644 index 00000000..7c0ace86 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/add-account.tsx @@ -0,0 +1,38 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Input, Button } from 'common-components/src' +import { XpenseApiService } from 'common-frontend-utils/src' + +export const AddXpenseAccountPage = Shade<{}, { accountName: string }>({ + getInitialState: () => ({ accountName: '' }), + render: ({ injector, getState, updateState }) => { + return ( +
+
{ + ev.preventDefault() + const { accountName: name } = getState() + injector.getInstance(XpenseApiService).call({ + method: 'POST', + action: '/accounts', + body: { + name, + }, + }) + }}> + updateState({ accountName: (ev.target as any).value }, true)} + /> + + + +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/index.tsx b/frontends/wrap-r/src/pages/xpense/index.tsx new file mode 100644 index 00000000..58aaafff --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -0,0 +1,41 @@ +import { Shade, createComponent, LocationService } from '@furystack/shades' +import { XpenseApiService } from 'common-frontend-utils/src' +import { xpense } from 'common-models' +import { AutoComplete, Button } from 'common-components/src' + +export const XpensePage = Shade< + {}, + { accounts: Array<{ name: string; ownerType: xpense.Account['ownerType']; ownerName: xpense.Account['ownerName'] }> } +>({ + getInitialState: () => ({ accounts: [] }), + constructed: async ({ injector, updateState }) => { + const accounts = await injector.getInstance(XpenseApiService).call({ + method: 'GET', + action: '/availableAccounts', + }) + updateState({ accounts }) + }, + render: ({ getState, injector }) => { + const { accounts } = getState() + return ( +
+ xpense +
+ + getSuggestions={async (term) => accounts.filter((a) => a.name.includes(term))} + renderSuggestion={(suggestion) =>
{suggestion.name}
} + /> + +
+ ) + }, +}) diff --git a/services/xpense/package.json b/services/xpense/package.json new file mode 100644 index 00000000..002881f6 --- /dev/null +++ b/services/xpense/package.json @@ -0,0 +1,27 @@ +{ + "name": "xpense-service", + "version": "1.0.0", + "main": "dist/index.js", + "license": "GPL-2.0-only", + "private": true, + "scripts": { + "start": "node ./dist/service.js", + "clean": "rimraf dist && rimraf tsconfig.tsbuildinfo", + "build": "tsc -b" + }, + "devDependencies": { + "@types/node": "^13.7.1" + }, + "dependencies": { + "@furystack/auth-google": "^4.0.0", + "@furystack/core": "^8.0.2", + "@furystack/inject": "^4.0.3", + "@furystack/logging": "^2.0.8", + "@furystack/mongodb-store": "^2.0.12", + "@furystack/repository": "^2.0.9", + "@furystack/rest": "^1.1.1", + "@furystack/rest-service": "^1.1.1", + "common-service-utils": "1.0.0", + "tslib": "^1.10.0" + } +} diff --git a/services/xpense/src/actions/get-account.ts b/services/xpense/src/actions/get-account.ts new file mode 100644 index 00000000..cef525da --- /dev/null +++ b/services/xpense/src/actions/get-account.ts @@ -0,0 +1,18 @@ +import { RequestAction, JsonResult, RequestError } from '@furystack/rest' +import { xpense } from 'common-models' + +export const GetAccount: RequestAction<{ + result: xpense.Account + query: { type: 'user' | 'organization'; owner: string; accountName: string } +}> = async ({ injector, getQuery }) => { + const { accountName, owner, type } = getQuery() + const ds = injector.getDataSetFor('accounts') + const [account] = await ds.filter(injector, { + filter: { name: accountName, ownerType: type, ownerName: owner }, + top: 1, + }) + if (!account) { + throw new RequestError('Account not found!', 404) + } + return JsonResult(account) +} diff --git a/services/xpense/src/actions/get-available-accounts.ts b/services/xpense/src/actions/get-available-accounts.ts new file mode 100644 index 00000000..30420bc8 --- /dev/null +++ b/services/xpense/src/actions/get-available-accounts.ts @@ -0,0 +1,10 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' + +export const GetAvailableAccounts: RequestAction<{ + result: Array<{ name: string; ownerType: xpense.Account['ownerType']; ownerName: xpense.Account['ownerName'] }> +}> = async ({ injector }) => { + const ds = injector.getDataSetFor('accounts') + const entries = await ds.filter(injector, { select: ['name', 'ownerName', 'ownerType'] }) + return JsonResult(entries) +} diff --git a/services/xpense/src/actions/get-items.ts b/services/xpense/src/actions/get-items.ts new file mode 100644 index 00000000..d88a6b2b --- /dev/null +++ b/services/xpense/src/actions/get-items.ts @@ -0,0 +1,10 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' + +export const GetItems: RequestAction<{ + result: xpense.Item[] +}> = async ({ injector }) => { + const ds = injector.getDataSetFor('items') + const entries = await ds.filter(injector, {}) + return JsonResult(entries) +} diff --git a/services/xpense/src/actions/get-shops.ts b/services/xpense/src/actions/get-shops.ts new file mode 100644 index 00000000..da644430 --- /dev/null +++ b/services/xpense/src/actions/get-shops.ts @@ -0,0 +1,10 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' + +export const GetShops: RequestAction<{ + result: xpense.Shop[] +}> = async ({ injector }) => { + const ds = injector.getDataSetFor('shops') + const entries = await ds.filter(injector, {}) + return JsonResult(entries as xpense.Shop[]) +} diff --git a/services/xpense/src/actions/index.ts b/services/xpense/src/actions/index.ts new file mode 100644 index 00000000..91c31cde --- /dev/null +++ b/services/xpense/src/actions/index.ts @@ -0,0 +1,9 @@ +export * from './get-account' +export * from './get-available-accounts' +export * from './get-shops' +export * from './get-items' +export * from './post-account' +export * from './post-item' +export * from './post-shop' +export * from './post-shopping' +export * from './post-replenishment' diff --git a/services/xpense/src/actions/post-account.ts b/services/xpense/src/actions/post-account.ts new file mode 100644 index 00000000..0f2aac1d --- /dev/null +++ b/services/xpense/src/actions/post-account.ts @@ -0,0 +1,21 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' +import { HttpUserContext } from '@furystack/rest-service' + +export const PostAccount: RequestAction<{ result: xpense.Account; body: { name: string } }> = async ({ + injector, + getBody, +}) => { + const { name } = await getBody() + const ds = injector.getDataSetFor('accounts') + const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() + + const created = await ds.add(injector, { + name, + ownerType: 'user', + ownerName: currentUser.username, + createdBy: currentUser.username, + creationDate: new Date().toISOString(), + } as xpense.Account) + return JsonResult(created) +} diff --git a/services/xpense/src/actions/post-item.ts b/services/xpense/src/actions/post-item.ts new file mode 100644 index 00000000..6ee17425 --- /dev/null +++ b/services/xpense/src/actions/post-item.ts @@ -0,0 +1,19 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' +import { HttpUserContext } from '@furystack/rest-service' + +export const PostItem: RequestAction<{ + result: xpense.Item + body: { name: string; description: string; category?: string } +}> = async ({ injector, getBody }) => { + const postData = await getBody() + const ds = injector.getDataSetFor('items') + const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() + + const created = await ds.add(injector, { + ...postData, + createdBy: currentUser.username, + creationDate: new Date().toISOString(), + } as xpense.Item) + return JsonResult(created) +} diff --git a/services/xpense/src/actions/post-replenishment.ts b/services/xpense/src/actions/post-replenishment.ts new file mode 100644 index 00000000..0ae7c2de --- /dev/null +++ b/services/xpense/src/actions/post-replenishment.ts @@ -0,0 +1,43 @@ +import { RequestAction, JsonResult, RequestError } from '@furystack/rest' +import { xpense } from 'common-models' +import { HttpUserContext } from '@furystack/rest-service' + +export const PostReplenishment: RequestAction<{ + body: { amount: number; comment?: string } + query: { type: 'user' | 'organization'; owner: string; accountName: string } + result: xpense.Replenishment +}> = async ({ injector, getBody, getQuery }) => { + const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() + const body = await getBody() + const { accountName, owner, type } = getQuery() + const ds = injector.getDataSetFor('accounts') + const [account] = await ds.filter(injector, { + filter: { name: accountName, ownerType: type, ownerName: owner }, + top: 1, + }) + if (!account) { + throw new RequestError('Account not found!', 404) + } + + const createdReplenishment = await injector.getDataSetFor('replenishments').add(injector, { + ...body, + createdBy: currentUser.username, + accountId: account._id, + } as xpense.Replenishment) + + await ds.update(injector, account._id, { + ...account, + history: [ + ...account.history, + { + balance: account.current + createdReplenishment.amount, + change: createdReplenishment.amount, + date: new Date().toISOString(), + changePerCategory: [], + relatedEntry: { replenishmentId: createdReplenishment._id }, + }, + ], + }) + + return JsonResult(createdReplenishment) +} diff --git a/services/xpense/src/actions/post-shop.ts b/services/xpense/src/actions/post-shop.ts new file mode 100644 index 00000000..2c2c8763 --- /dev/null +++ b/services/xpense/src/actions/post-shop.ts @@ -0,0 +1,19 @@ +import { RequestAction, JsonResult } from '@furystack/rest' +import { xpense } from 'common-models' +import { HttpUserContext } from '@furystack/rest-service' + +export const PostShop: RequestAction<{ result: xpense.Shop; body: { name: string } }> = async ({ + injector, + getBody, +}) => { + const postData = await getBody() + const ds = injector.getDataSetFor('shops') + const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() + + const created = await ds.add(injector, { + ...postData, + createdBy: currentUser.username, + creationDate: new Date().toISOString(), + } as xpense.Shop) + return JsonResult(created) +} diff --git a/services/xpense/src/actions/post-shopping.ts b/services/xpense/src/actions/post-shopping.ts new file mode 100644 index 00000000..1a1bd2e2 --- /dev/null +++ b/services/xpense/src/actions/post-shopping.ts @@ -0,0 +1,46 @@ +import { RequestAction, JsonResult, RequestError } from '@furystack/rest' +import { xpense } from 'common-models' +import { HttpUserContext } from '@furystack/rest-service' + +export const PostShopping: RequestAction<{ + body: Array<{ itemName: string; amount: number; unitPrice: number }> + query: { type: 'user' | 'organization'; owner: string; accountName: string } + result: xpense.Shopping +}> = async ({ injector, getBody, getQuery }) => { + const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() + const body = await getBody() + const { accountName, owner, type } = getQuery() + const ds = injector.getDataSetFor('accounts') + const [account] = await ds.filter(injector, { + filter: { name: accountName, ownerType: type, ownerName: owner }, + top: 1, + }) + if (!account) { + throw new RequestError('Account not found!', 404) + } + + const createdShopping = await injector.getDataSetFor('shoppings').add(injector, { + createdBy: currentUser.username, + entries: body, + creationDate: new Date().toISOString(), + accountId: account._id, + sumAmount: body.reduce((last, current) => last + current.amount, 0), + _id: '', + }) + + await ds.update(injector, account._id, { + ...account, + history: [ + ...account.history, + { + balance: account.current - createdShopping.sumAmount, + change: -createdShopping.sumAmount, + date: new Date().toISOString(), + changePerCategory: [], + relatedEntry: { shoppingId: createdShopping._id }, + }, + ], + }) + + return JsonResult(createdShopping) +} diff --git a/services/xpense/src/config.ts b/services/xpense/src/config.ts new file mode 100644 index 00000000..58ce4c60 --- /dev/null +++ b/services/xpense/src/config.ts @@ -0,0 +1,101 @@ +import '@furystack/auth-google' +import { verifyAndCreateIndexes } from 'common-service-utils' +import '@furystack/repository/dist/injector-extension' +import { ConsoleLogger } from '@furystack/logging' +import { Injector } from '@furystack/inject' +import { xpense } from 'common-models' +import { databases } from 'common-config' +import { HttpUserContext } from '@furystack/rest-service' + +export const injector = new Injector() + +injector.useDbLogger({ appName: 'xpense' }).useLogging(ConsoleLogger) +injector.useCommonHttpAuth() +injector.setupStores((sm) => + sm + .useMongoDb({ + model: xpense.Account, + url: databases.xpense.mongoUrl, + db: databases.xpense.dbName, + collection: databases.xpense.accounts, + options: databases.standardOptions, + }) + .useMongoDb({ + model: xpense.Item, + url: databases.xpense.mongoUrl, + db: databases.xpense.dbName, + collection: databases.xpense.items, + options: databases.standardOptions, + }) + .useMongoDb({ + model: xpense.Replenishment, + url: databases.xpense.mongoUrl, + db: databases.xpense.dbName, + collection: databases.xpense.replenishments, + options: databases.standardOptions, + }) + .useMongoDb({ + model: xpense.Shop, + url: databases.xpense.mongoUrl, + db: databases.xpense.dbName, + collection: databases.xpense.shops, + options: databases.standardOptions, + }) + .useMongoDb({ + model: xpense.Shopping, + url: databases.xpense.mongoUrl, + db: databases.xpense.dbName, + collection: databases.xpense.shoppings, + options: databases.standardOptions, + }), +) + +injector.setupRepository((repo) => + repo + .createDataSet(xpense.Account, { + name: 'accounts', + addFilter: async ({ injector: i }) => { + const user = await i.getInstance(HttpUserContext).getCurrentUser() + return { + filter: { + ownerName: user.username, + }, + } + }, + }) + .createDataSet(xpense.Item, { + name: 'items', + }) + .createDataSet(xpense.Replenishment, { + name: 'replenishments', + }) + .createDataSet(xpense.Shop, { + name: 'shops', + }) + .createDataSet(xpense.Shopping, { + name: 'shoppings', + }), +) + +verifyAndCreateIndexes({ + injector, + model: xpense.Item, + indexName: 'itemName', + indexSpecification: { name: 1 }, + indexOptions: { unique: true }, +}) +verifyAndCreateIndexes({ + injector, + model: xpense.Shop, + indexName: 'shopName', + indexSpecification: { name: 1 }, + indexOptions: { unique: true }, +}) + +verifyAndCreateIndexes({ + injector, + model: xpense.Account, + indexName: 'balanceOwner', + indexSpecification: { ownerName: 1, ownerType: 1, name: 1 }, + indexOptions: { unique: true }, +}) diff --git a/services/xpense/src/service.ts b/services/xpense/src/service.ts new file mode 100644 index 00000000..6dbe224f --- /dev/null +++ b/services/xpense/src/service.ts @@ -0,0 +1,41 @@ +import { sites } from 'common-config' +import { apis } from 'common-models' +import { attachShutdownHandler } from 'common-service-utils' +import { injector } from './config' +import { + GetItems, + GetShops, + GetAccount, + PostShop, + PostItem, + PostReplenishment, + PostShopping, + GetAvailableAccounts, + PostAccount, +} from './actions' + +injector.useRestService({ + port: parseInt(sites.services.xpense.internalPort as string, 10), + root: '/api/xpense', + api: { + GET: { + '/shops': GetShops, + '/items': GetItems, + '/:type/:owner/:accountName': GetAccount, + '/availableAccounts': GetAvailableAccounts, + }, + POST: { + '/items': PostItem, + '/shops': PostShop, + '/accounts': PostAccount, + '/:type/:owner/:accountName/replenish': PostReplenishment, + '/:type/:owner/:accountName/shop': PostShopping, + }, + }, + cors: { + credentials: true, + origins: Object.values(sites.frontends), + }, +}) + +attachShutdownHandler(injector) diff --git a/services/xpense/tsconfig.json b/services/xpense/tsconfig.json new file mode 100644 index 00000000..95c5810d --- /dev/null +++ b/services/xpense/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": ["src"], + "references": [{ "path": "../../common/service-utils" }] +} From 79b9fcd696e56d8631b2c7d6bb54ea147f09be96 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sat, 4 Apr 2020 19:49:04 +0200 Subject: [PATCH 02/27] feat(xpense): skeleton vol 2. --- .vscode/launch.json | 2 +- common/components/src/index.ts | 2 + common/components/src/tabs.tsx | 11 ++++- common/config/src/databases.ts | 12 +++++ common/config/src/sites.ts | 4 ++ common/frontend-utils/src/apis/logg-r-api.ts | 4 +- common/frontend-utils/src/apis/wrap-r-api.ts | 4 +- common/frontend-utils/src/index.ts | 1 + common/frontend-utils/src/service-list.ts | 9 ++++ common/models/src/apis/index.ts | 1 + common/models/src/index.ts | 3 +- .../service-utils/src/use-common-http-auth.ts | 2 +- common/service-utils/src/use-db-logger.ts | 6 +-- frontends/wrap-r/src/components/body.tsx | 46 +++++++++++++++++-- .../wrap-r/src/pages/organizations/index.tsx | 21 +++++++-- frontends/wrap-r/src/pages/profile.tsx | 23 ++-------- package.json | 1 + services/logg-r/package.json | 4 +- services/logg-r/src/service.ts | 4 +- services/tsconfig.json | 2 +- .../wrap-r/src/actions/post-organization.ts | 2 +- services/wrap-r/src/config.ts | 16 ++----- services/wrap-r/src/service.ts | 4 +- 23 files changed, 126 insertions(+), 58 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 608e0f02..6a3ff750 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,7 @@ "id": "service", "type": "pickString", "description": "Select one service from above", - "options": ["wrap-r", "logg-r"] + "options": ["wrap-r", "logg-r", "xpense"] } ] } diff --git a/common/components/src/index.ts b/common/components/src/index.ts index 3df3a8e1..00354012 100644 --- a/common/components/src/index.ts +++ b/common/components/src/index.ts @@ -1,8 +1,10 @@ export * from './animations' export * from './app-bar' +export * from './auto-complete' export * from './avatar' export * from './button' export * from './data-grid' +export * from './fab' export * from './grid' export * from './input' export * from './modal' diff --git a/common/components/src/tabs.tsx b/common/components/src/tabs.tsx index ee142d4e..0e5d51a3 100644 --- a/common/components/src/tabs.tsx +++ b/common/components/src/tabs.tsx @@ -1,4 +1,4 @@ -import { Shade, createComponent, PartialElement } from '@furystack/shades' +import { Shade, createComponent, PartialElement, LocationService } from '@furystack/shades' import { promisifyAnimation } from 'common-frontend-utils/src' export interface Tab { @@ -18,6 +18,15 @@ export const Tabs = Shade< >({ shadowDomName: 'shade-tabs', getInitialState: ({ props }) => ({ activeIndex: props.activeTab || 0 }), + constructed: ({ injector, updateState }) => { + const locationSubscription = injector.getInstance(LocationService).onLocationChanged.subscribe((loc) => { + if (loc.hash && loc.hash.startsWith('#tab-')) { + const page = parseInt(loc.hash.replace('#tab-', ''), 10) + page && updateState({ activeIndex: page }) + } + }, true) + return () => locationSubscription.dispose() + }, render: ({ props, getState, updateState }) => { return (
diff --git a/common/config/src/databases.ts b/common/config/src/databases.ts index d790af44..bae6ac48 100644 --- a/common/config/src/databases.ts +++ b/common/config/src/databases.ts @@ -13,4 +13,16 @@ export const databases = { dbName: 'multiverse-logs', logCollection: 'entries', }, + xpense: { + mongoUrl: process.env.MONGO_XPENSE_URL || process.env.MONGO_URL || 'mongodb://localhost:27017', + dbName: 'multiverse-xpense', + accounts: 'accounts', + items: 'items', + replenishments: 'replenishments', + shops: 'shops', + shoppings: 'private-xpenses', + }, + standardOptions: { + useUnifiedTopology: true, + }, } diff --git a/common/config/src/sites.ts b/common/config/src/sites.ts index 90e4fcfb..e04059a1 100644 --- a/common/config/src/sites.ts +++ b/common/config/src/sites.ts @@ -8,6 +8,10 @@ export const sites = { internalPort: process.env.LOGGR_SERVICE_INTERNAL_PORT || 9091, externalPath: process.env.LOGGR_SERVICE_EXTENAL_URL || 'http://localhost:9091/api', }, + xpense: { + internalPort: process.env.XPENSE_SERVICE_INTERNAL_PORT || 9092, + externalPath: process.env.XPENSE_SERVICE_EXTENAL_URL || 'http://localhost:9092/api', + }, }, frontends: { 'wrap-r': process.env.WRAPPR_FRONTEND_URL || 'http://localhost:8080', diff --git a/common/frontend-utils/src/apis/logg-r-api.ts b/common/frontend-utils/src/apis/logg-r-api.ts index 854cb65d..821ebf3e 100644 --- a/common/frontend-utils/src/apis/logg-r-api.ts +++ b/common/frontend-utils/src/apis/logg-r-api.ts @@ -1,12 +1,12 @@ import { Injectable } from '@furystack/inject' import { PathHelper } from '@furystack/utils' -import { LoggRApi } from 'common-models' +import { apis } from 'common-models' import { createClient } from '@furystack/rest-client-fetch' import { sites } from 'common-config' @Injectable({ lifetime: 'singleton' }) export class LoggRApiService { - public call = createClient({ + public call = createClient({ endpointUrl: PathHelper.joinPaths(sites.services['logg-r'].externalPath, '/logg-r'), requestInit: { credentials: 'include', diff --git a/common/frontend-utils/src/apis/wrap-r-api.ts b/common/frontend-utils/src/apis/wrap-r-api.ts index 1a8e1121..567c975d 100644 --- a/common/frontend-utils/src/apis/wrap-r-api.ts +++ b/common/frontend-utils/src/apis/wrap-r-api.ts @@ -1,12 +1,12 @@ import { Injectable } from '@furystack/inject' import { PathHelper } from '@furystack/utils' -import { WrapRApi } from 'common-models' +import { apis } from 'common-models' import { createClient } from '@furystack/rest-client-fetch' import { sites } from 'common-config' @Injectable({ lifetime: 'singleton' }) export class WrapRApiService { - public call = createClient({ + public call = createClient({ endpointUrl: PathHelper.joinPaths(sites.services['wrap-r'].externalPath, '/wrap-r'), requestInit: { credentials: 'include', diff --git a/common/frontend-utils/src/index.ts b/common/frontend-utils/src/index.ts index 9da22eaa..73763782 100644 --- a/common/frontend-utils/src/index.ts +++ b/common/frontend-utils/src/index.ts @@ -1,5 +1,6 @@ export * from './apis/logg-r-api' export * from './apis/wrap-r-api' +export * from './apis/xpense-api' export * from './collection-service' export * from './get-error-message' export * from './service-list' diff --git a/common/frontend-utils/src/service-list.ts b/common/frontend-utils/src/service-list.ts index a4424be1..7c1bb8bf 100644 --- a/common/frontend-utils/src/service-list.ts +++ b/common/frontend-utils/src/service-list.ts @@ -20,6 +20,15 @@ export const serviceList: ServiceDescription[] = [ showInDashboard: true, requiredRoles: ['terms-accepted'], }, + { + name: 'Xpense', + icon: '💰', + description: 'Manage accounts, incomes and expences', + showInMenu: true, + showInDashboard: true, + requiredRoles: ['terms-accepted'], + url: '/xpense', + }, { name: 'Organizations', icon: '🏢', diff --git a/common/models/src/apis/index.ts b/common/models/src/apis/index.ts index b3a5060e..1e5ac071 100644 --- a/common/models/src/apis/index.ts +++ b/common/models/src/apis/index.ts @@ -1,3 +1,4 @@ export * from './session-api' export * from './logg-r-api' export * from './wrap-r-api' +export * from './xpense-api' diff --git a/common/models/src/index.ts b/common/models/src/index.ts index de310120..27b8fa35 100644 --- a/common/models/src/index.ts +++ b/common/models/src/index.ts @@ -1,4 +1,4 @@ -export * from './apis' +export * as apis from './apis' export * from './github-account' export * from './github-api-payload' export * from './google-account' @@ -7,3 +7,4 @@ export * from './log-entry' export * from './profile' export * from './session' export * from './user' +export * as xpense from './xpense' diff --git a/common/service-utils/src/use-common-http-auth.ts b/common/service-utils/src/use-common-http-auth.ts index 32433d3a..50a61866 100644 --- a/common/service-utils/src/use-common-http-auth.ts +++ b/common/service-utils/src/use-common-http-auth.ts @@ -21,7 +21,7 @@ Injector.prototype.useCommonHttpAuth = function () { url: databases['common-auth'].mongoUrl, db: databases['common-auth'].dbName, collection: databases['common-auth'].usersCollection, - options: { useUnifiedTopology: true }, + options: databases.standardOptions, }) .useRedis( Session, diff --git a/common/service-utils/src/use-db-logger.ts b/common/service-utils/src/use-db-logger.ts index 5d6040e8..084d8ef4 100644 --- a/common/service-utils/src/use-db-logger.ts +++ b/common/service-utils/src/use-db-logger.ts @@ -47,16 +47,14 @@ declare module '@furystack/inject/dist/injector' { } } -Injector.prototype.useDbLogger = function(settings) { +Injector.prototype.useDbLogger = function (settings) { this.setupStores((sm) => sm.useMongoDb({ model: LogEntry, collection: databases.logging.logCollection, url: databases.logging.mongoUrl, db: databases.logging.dbName, - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }), ) diff --git a/frontends/wrap-r/src/components/body.tsx b/frontends/wrap-r/src/components/body.tsx index e60165e5..66efcc36 100644 --- a/frontends/wrap-r/src/components/body.tsx +++ b/frontends/wrap-r/src/components/body.tsx @@ -85,14 +85,54 @@ export const Body = Shade< ), }, { - url: '/organizations/:organizationId', + url: '/add-organization', + component: () => ( + { + const { AddOrganizationPage } = await import( + /* webpackChunkName: "add-organization" */ '../pages/organizations/add-organization' + ) + return + }} + loader={} + /> + ), + }, + { + url: '/organization/:organizationName', component: ({ match }) => ( { const { OrganizationDetailsPage } = await import( - /* webpackChunkName: "organizations" */ '../pages/organizations/organization-details' + /* webpackChunkName: "edit-organization" */ '../pages/organizations/organization-details' + ) + return + }} + loader={} + /> + ), + }, + { + url: '/xpense', + component: () => ( + { + const { XpensePage } = await import(/* webpackChunkName: "xpense" */ '../pages/xpense') + return + }} + loader={} + /> + ), + }, + { + url: '/xpense/add-account', + component: () => ( + { + const { AddXpenseAccountPage } = await import( + /* webpackChunkName: "xpense-add-account" */ '../pages/xpense/add-account' ) - return + return }} loader={} /> diff --git a/frontends/wrap-r/src/pages/organizations/index.tsx b/frontends/wrap-r/src/pages/organizations/index.tsx index 7b534e37..fbf7d344 100644 --- a/frontends/wrap-r/src/pages/organizations/index.tsx +++ b/frontends/wrap-r/src/pages/organizations/index.tsx @@ -1,7 +1,7 @@ -import { Shade, createComponent } from '@furystack/shades' +import { Shade, createComponent, LocationService } from '@furystack/shades' import { CollectionService, WrapRApiService } from 'common-frontend-utils' import { Organization } from 'common-models' -import { DataGrid, styles } from 'common-components' +import { DataGrid, styles, Fab, colors } from 'common-components' export const OrganizationsPage = Shade<{}, { service: CollectionService }>({ shadowDomName: 'shade-organizations-page', @@ -17,7 +17,7 @@ export const OrganizationsPage = Shade<{}, { service: CollectionService { + render: ({ getState, injector }) => { return (
@@ -26,8 +26,21 @@ export const OrganizationsPage = Shade<{}, { service: CollectionService history.pushState({}, '', `/organizations/${encodeURIComponent(entry.name)}`)} + onDoubleClick={(entry) => { + history.pushState({}, '', `/organization/${encodeURIComponent(entry.name)}`) + injector.getInstance(LocationService).updateState() + }} /> + { + /** */ + history.pushState({}, '', '/add-organization') + injector.getInstance(LocationService).updateState() + }}> + ➕ +
) }, diff --git a/frontends/wrap-r/src/pages/profile.tsx b/frontends/wrap-r/src/pages/profile.tsx index a7c8d891..7da639fd 100644 --- a/frontends/wrap-r/src/pages/profile.tsx +++ b/frontends/wrap-r/src/pages/profile.tsx @@ -1,4 +1,4 @@ -import { createComponent, Shade, LocationService } from '@furystack/shades' +import { createComponent, Shade } from '@furystack/shades' import { Tabs, styles, Avatar, Input, Button, colors } from 'common-components' import { User, Profile, GithubAccount, GoogleAccount } from 'common-models' import { WrapRApiService, SessionService } from 'common-frontend-utils' @@ -10,13 +10,12 @@ import { Init } from './init' export const ProfilePage = Shade< {}, { - currentTab: number currentUser?: User profile?: Profile loginProviderDetails?: { hasPassword: boolean; google?: GoogleAccount; github?: GithubAccount } } >({ - getInitialState: () => ({ currentTab: 0 }), + getInitialState: () => ({}), constructed: async ({ injector, updateState }) => { const currentUser = injector.getInstance(SessionService).currentUser.getValue() as User const api = injector.getInstance(WrapRApiService) @@ -29,21 +28,14 @@ export const ProfilePage = Shade< method: 'GET', action: '/loginProviderDetails', }) - const locationSubscription = injector.getInstance(LocationService).onLocationChanged.subscribe((loc) => { - if (loc.hash && loc.hash.startsWith('#tab-')) { - const page = parseInt(loc.hash.replace('#tab-', ''), 10) - page && updateState({ currentTab: page }) - } - }, true) updateState({ currentUser, profile, loginProviderDetails, }) - return () => locationSubscription.dispose() }, render: ({ getState, injector, updateState }) => { - const { currentUser, profile, loginProviderDetails, currentTab } = getState() + const { currentUser, profile, loginProviderDetails } = getState() if (!currentUser || !profile || !loginProviderDetails) { return } @@ -61,8 +53,6 @@ export const ProfilePage = Shade< return ( updateState({ currentTab: tab }, true)} tabs={[ { header:
🎴 General info
, @@ -91,12 +81,7 @@ export const ProfilePage = Shade< {loginProviderDetails.hasPassword ? (
The password has been set up correctly. You can change your password{' '} - { - ev.preventDefault() - updateState({ currentTab: 2 }) - }}> + here {' '}
diff --git a/package.json b/package.json index a44b633a..5249107b 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "start": "concurrently --kill-others \"yarn startWrapR\" \"yarn startLoggR\" \"yarn startFrontend\" ", "startWrapR": "yarn workspace wrap-r-service start", "startLoggR": "yarn workspace logg-r-service start", + "startXpense": "yarn workspace xpense-service start", "startFrontend": "yarn workspace wrap-r-frontend start", "clean": "rimraf services/**/dist frontends/**/dist frontends/**/bundle common/**/dist **/tsconfig.tsbuildinfo tsconfig.tsbuildinfo", "lint": "eslint . --ext .tsx,.ts --cache", diff --git a/services/logg-r/package.json b/services/logg-r/package.json index f6be3bad..838d17a6 100644 --- a/services/logg-r/package.json +++ b/services/logg-r/package.json @@ -5,8 +5,8 @@ "license": "GPL-2.0-only", "private": true, "scripts": { - "start": "yarn build && node ./dist/service.js", - "seed": "yarn build && node ./dist/seed.js", + "start": "node ./dist/service.js", + "seed": "node ./dist/seed.js", "clean": "rimraf dist && rimraf tsconfig.tsbuildinfo", "build": "tsc -b" }, diff --git a/services/logg-r/src/service.ts b/services/logg-r/src/service.ts index 2ef8bb78..fd573879 100644 --- a/services/logg-r/src/service.ts +++ b/services/logg-r/src/service.ts @@ -1,11 +1,11 @@ import { sites } from 'common-config' -import { LoggRApi } from 'common-models' +import { apis } from 'common-models' import { Authorize } from '@furystack/rest-service' import { attachShutdownHandler } from 'common-service-utils' import { injector } from './config' import { GetEntries, GetEntry } from './actions' -injector.useRestService({ +injector.useRestService({ port: parseInt(sites.services['logg-r'].internalPort as string, 10), root: '/api/logg-r', api: { diff --git a/services/tsconfig.json b/services/tsconfig.json index 1a0a8a17..4869152e 100644 --- a/services/tsconfig.json +++ b/services/tsconfig.json @@ -3,5 +3,5 @@ "composite": true }, "include": [], - "references": [{ "path": "wrap-r" }, { "path": "logg-r" }] + "references": [{ "path": "wrap-r" }, { "path": "logg-r" }, { "path": "xpense" }] } diff --git a/services/wrap-r/src/actions/post-organization.ts b/services/wrap-r/src/actions/post-organization.ts index c5b23f83..aceaf332 100644 --- a/services/wrap-r/src/actions/post-organization.ts +++ b/services/wrap-r/src/actions/post-organization.ts @@ -6,7 +6,7 @@ export const PostOrganization: RequestAction<{ body: Omit; injector, getBody, }) => { - const dataSet = injector.getDataSetFor(Organization) + const dataSet = injector.getDataSetFor('organizations') const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() const postData = await getBody() const result = await dataSet.add(injector, { ...postData, ownerName: currentUser.username } as any) diff --git a/services/wrap-r/src/config.ts b/services/wrap-r/src/config.ts index 379063fe..4daa3605 100644 --- a/services/wrap-r/src/config.ts +++ b/services/wrap-r/src/config.ts @@ -36,36 +36,28 @@ injector url: databases['common-auth'].mongoUrl, db: databases['common-auth'].dbName, collection: 'google-accounts', - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }) .useMongoDb({ model: GithubAccount, url: databases['common-auth'].mongoUrl, db: databases['common-auth'].dbName, collection: 'github-accounts', - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }) .useMongoDb({ model: Organization, url: databases['common-auth'].mongoUrl, db: databases['common-auth'].dbName, collection: 'organizations', - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }) .useMongoDb({ model: Profile, url: databases['common-auth'].mongoUrl, db: databases['common-auth'].dbName, collection: 'profiles', - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }), ) .useLogging(ConsoleLogger) diff --git a/services/wrap-r/src/service.ts b/services/wrap-r/src/service.ts index de4f6b47..9d758c73 100644 --- a/services/wrap-r/src/service.ts +++ b/services/wrap-r/src/service.ts @@ -1,6 +1,6 @@ import { GetCurrentUser, IsAuthenticated, LoginAction, LogoutAction, Authenticate } from '@furystack/rest-service' import { sites } from 'common-config' -import { User, WrapRApi } from 'common-models' +import { User, apis } from 'common-models' import { RequestAction } from '@furystack/rest' import { attachShutdownHandler } from 'common-service-utils' import { @@ -25,7 +25,7 @@ import { } from './actions' import { injector } from './config' -injector.useRestService({ +injector.useRestService({ port: parseInt(sites.services['wrap-r'].internalPort as any, 10), root: '/api/wrap-r', api: { From 46d2b7cef1deee518d613a9f25e0643b84007dc8 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 5 Apr 2020 13:59:51 +0200 Subject: [PATCH 03/27] chore(wrap-r-frontend):removed autocomplete, added datalist (wip) --- common/components/package.json | 2 +- common/components/src/auto-complete.tsx | 58 --------------------- common/components/src/index.ts | 1 - common/components/src/modal.tsx | 9 ++-- frontends/wrap-r/src/pages/xpense/index.tsx | 28 +++++++--- 5 files changed, 26 insertions(+), 72 deletions(-) delete mode 100644 common/components/src/auto-complete.tsx diff --git a/common/components/package.json b/common/components/package.json index 1119c617..d30a3346 100644 --- a/common/components/package.json +++ b/common/components/package.json @@ -4,7 +4,7 @@ "description": "", "private": true, "scripts": { - "build": "tsc", + "build": "tsc --b", "clean": "rimraf dist", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/common/components/src/auto-complete.tsx b/common/components/src/auto-complete.tsx deleted file mode 100644 index 9fd03942..00000000 --- a/common/components/src/auto-complete.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Shade, createComponent, ChildrenList } from '@furystack/shades' -import { debounce, ObservableValue } from '@furystack/utils' - -export interface AutoCompleteProps { - style?: CSSStyleDeclaration - debounce?: number - value?: string - getSuggestions: (term: string) => Promise - renderSuggestion: (item: T) => JSX.Element -} - -export interface AutoCompleteState { - isOpened: ObservableValue - suggestions: ObservableValue - value?: T - fetchFn: (term: string) => Promise -} - -export const AutoCompleteSuggestionList = Shade<{ suggestions: ObservableValue }>({ - render: () => { - return
- }, -}) - -export const AutoComplete: (props: AutoCompleteProps, children: ChildrenList) => JSX.Element = Shade< - AutoCompleteProps, - AutoCompleteState ->({ - shadowDomName: 'shade-auto-complete', - getInitialState: ({ props }) => ({ - isOpened: new ObservableValue(false), - value: props.value, - suggestions: new ObservableValue([]), - fetchFn: debounce(props.getSuggestions, props.debounce || 500), - }), - render: ({ props, updateState, getState }) => { - const { isOpened, suggestions, fetchFn } = getState() - return ( -
- isOpened.setValue(true)} - value={getState().value} - onblur={() => { - getState().isOpened.setValue(false) - }} - onkeyup={async (ev) => { - const value = (ev.target as any).value as string - updateState({ value }, true) - const s = await fetchFn(value) - suggestions.setValue(s) - }} - /> - -
- ) - }, -}) diff --git a/common/components/src/index.ts b/common/components/src/index.ts index 00354012..4944a2a5 100644 --- a/common/components/src/index.ts +++ b/common/components/src/index.ts @@ -1,6 +1,5 @@ export * from './animations' export * from './app-bar' -export * from './auto-complete' export * from './avatar' export * from './button' export * from './data-grid' diff --git a/common/components/src/modal.tsx b/common/components/src/modal.tsx index 4cd86168..1f92c201 100644 --- a/common/components/src/modal.tsx +++ b/common/components/src/modal.tsx @@ -10,12 +10,13 @@ export const Modal = Shade<{ isVisible: boolean; onClose?: () => void }>({ style={{ width: '100%', height: '100%', - backgroundColor: 'rgba(128,128,128,0.03)', - position: 'absolute', + display: 'block', + // backgroundColor: 'rgba(128,128,128,0.03)', + position: 'fixed', top: '0', left: '0', - animation: 'show 100ms linear', - backdropFilter: 'contrast(0.3)brightness(0.2)blur(5px)', + // animation: 'show 100ms linear', + // backdropFilter: 'contrast(0.3)brightness(0.2)blur(5px)', }}> {children}
diff --git a/frontends/wrap-r/src/pages/xpense/index.tsx b/frontends/wrap-r/src/pages/xpense/index.tsx index 58aaafff..c33d6b0d 100644 --- a/frontends/wrap-r/src/pages/xpense/index.tsx +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -1,13 +1,17 @@ import { Shade, createComponent, LocationService } from '@furystack/shades' -import { XpenseApiService } from 'common-frontend-utils/src' +import { XpenseApiService } from 'common-frontend-utils' import { xpense } from 'common-models' -import { AutoComplete, Button } from 'common-components/src' +import { Input, Button } from 'common-components' +import { v4 } from 'uuid' export const XpensePage = Shade< {}, - { accounts: Array<{ name: string; ownerType: xpense.Account['ownerType']; ownerName: xpense.Account['ownerName'] }> } + { + accounts: Array<{ name: string; ownerType: xpense.Account['ownerType']; ownerName: xpense.Account['ownerName'] }> + accountSelectorId: string + } >({ - getInitialState: () => ({ accounts: [] }), + getInitialState: () => ({ accounts: [], accountSelectorId: v4() }), constructed: async ({ injector, updateState }) => { const accounts = await injector.getInstance(XpenseApiService).call({ method: 'GET', @@ -16,15 +20,23 @@ export const XpensePage = Shade< updateState({ accounts }) }, render: ({ getState, injector }) => { - const { accounts } = getState() + const { accounts, accountSelectorId } = getState() return (
xpense
- - getSuggestions={async (term) => accounts.filter((a) => a.name.includes(term))} + + + {accounts.map((ac) => ( + + {/* + getSuggestions={async (term) => accounts.filter((a) => a.name.toLowerCase().includes(term.toLowerCase()))} renderSuggestion={(suggestion) =>
{suggestion.name}
} - /> + onSelectSuggestion={(s) => console.log(s)} + stringifySuggestion={(s) => s.name} + /> */} +
+
+

{selectedAccount?.name}

+

{selectedAccount?.current}

+
+ { + history.pushState({}, '', `/xpense/${account}`) + updateState({ selectedAccountName: account }) + }} + /> + +
+ {selectedAccountName ? ( +
+ + +
+ ) : ( +
...select an account first...
+ )}
) }, diff --git a/frontends/wrap-r/src/pages/xpense/replenish.tsx b/frontends/wrap-r/src/pages/xpense/replenish.tsx new file mode 100644 index 00000000..3b7a60d3 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/replenish.tsx @@ -0,0 +1,108 @@ +import { Shade, createComponent } from '@furystack/shades' +import { styles, Input, Button, colors } from 'common-components' +import { XpenseApiService } from 'common-frontend-utils' +import { xpense } from 'common-models' +import { Init } from '../init' + +export const ReplenishPage = Shade< + { accountOwner: string; accountType: 'user' | 'organization'; accountName: string }, + { amount: number; comment?: string; error?: Error; account?: xpense.Account } +>({ + getInitialState: () => ({ + amount: 0, + }), + constructed: async ({ injector, props, updateState }) => { + try { + const account: xpense.Account = await injector.getInstance(XpenseApiService).call({ + method: 'GET', + action: '/:type/:owner/:accountName', + query: { + accountName: props.accountName, + owner: props.accountOwner, + type: props.accountType, + }, + }) + updateState({ account }) + } catch (error) { + updateState({ error }) + } + }, + shadowDomName: 'replenish-page', + render: ({ props, getState, updateState, injector }) => { + const { account, error } = getState() + if (!account) { + return + } + if (error) { + return ( +
+
+

WhoOoOops... 😱

+

Failed to add to the balance 😓

+

Something went wrong during replenishing the balance for account '{props.accountName}'

+
{JSON.stringify(error)}
+
+ Go to home... +
+ ) + } + return ( +
+

Replenish the balance for {props.accountName}

+

+ The current balance is: {account.current || 0} +

+
{ + ev.preventDefault() + injector + .getInstance(XpenseApiService) + .call({ + method: 'POST', + action: '/:type/:owner/:accountName/replenish', + body: { + amount: getState().amount, + comment: getState().comment, + }, + }) + .catch((e) => updateState({ error: e })) + }}> + { + updateState({ amount: parseInt((ev.target as any).value as string, 10) }) + }} + required + /> + { + updateState({ comment: (ev.target as any).value }) + }} + /> + +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/shopping.tsx b/frontends/wrap-r/src/pages/xpense/shopping.tsx new file mode 100644 index 00000000..785e5c88 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -0,0 +1,12 @@ +import { Shade, createComponent } from '@furystack/shades' + +export const XpenseShoppingPage = Shade<{ + accountOwner: string + accountType: 'user' | 'organization' + accountName: string +}>({ + shadowDomName: 'xpense-shopping-page', + render: ({ props }) => { + return
Shopping from the account {props.accountName}
+ }, +}) diff --git a/services/xpense/src/actions/get-available-accounts.ts b/services/xpense/src/actions/get-available-accounts.ts index 30420bc8..f10ae79e 100644 --- a/services/xpense/src/actions/get-available-accounts.ts +++ b/services/xpense/src/actions/get-available-accounts.ts @@ -5,6 +5,6 @@ export const GetAvailableAccounts: RequestAction<{ result: Array<{ name: string; ownerType: xpense.Account['ownerType']; ownerName: xpense.Account['ownerName'] }> }> = async ({ injector }) => { const ds = injector.getDataSetFor('accounts') - const entries = await ds.filter(injector, { select: ['name', 'ownerName', 'ownerType'] }) + const entries = await ds.filter(injector, { select: ['name', 'ownerName', 'ownerType', 'current'] }) return JsonResult(entries) } diff --git a/services/xpense/src/actions/post-account.ts b/services/xpense/src/actions/post-account.ts index 0f2aac1d..3419d0f1 100644 --- a/services/xpense/src/actions/post-account.ts +++ b/services/xpense/src/actions/post-account.ts @@ -16,6 +16,8 @@ export const PostAccount: RequestAction<{ result: xpense.Account; body: { name: ownerName: currentUser.username, createdBy: currentUser.username, creationDate: new Date().toISOString(), + history: [] as xpense.Account['history'], + current: 0, } as xpense.Account) return JsonResult(created) } diff --git a/services/xpense/src/actions/post-replenishment.ts b/services/xpense/src/actions/post-replenishment.ts index 0ae7c2de..fdc5d3bc 100644 --- a/services/xpense/src/actions/post-replenishment.ts +++ b/services/xpense/src/actions/post-replenishment.ts @@ -25,12 +25,14 @@ export const PostReplenishment: RequestAction<{ accountId: account._id, } as xpense.Replenishment) + const current = account.current + createdReplenishment.amount await ds.update(injector, account._id, { ...account, + current, history: [ ...account.history, { - balance: account.current + createdReplenishment.amount, + balance: current, change: createdReplenishment.amount, date: new Date().toISOString(), changePerCategory: [], From c15ddda51cc4355c3eab15f15341e39c1b8df01e Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Sun, 5 Apr 2020 19:50:06 +0200 Subject: [PATCH 06/27] fix(xpense): frontend urls, replenish, etc... --- frontends/wrap-r/src/components/body.tsx | 6 +++--- frontends/wrap-r/src/pages/xpense/index.tsx | 11 ++++++----- frontends/wrap-r/src/pages/xpense/replenish.tsx | 16 +++++++++------- .../xpense/src/actions/get-available-accounts.ts | 7 ++++++- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/frontends/wrap-r/src/components/body.tsx b/frontends/wrap-r/src/components/body.tsx index 0c8a75e4..8645ac9a 100644 --- a/frontends/wrap-r/src/components/body.tsx +++ b/frontends/wrap-r/src/components/body.tsx @@ -134,9 +134,9 @@ export const Body = Shade< const { XpensePage } = await import(/* webpackChunkName: "xpense" */ '../pages/xpense') return ( ) }} diff --git a/frontends/wrap-r/src/pages/xpense/index.tsx b/frontends/wrap-r/src/pages/xpense/index.tsx index 038e3b23..d7a1cfc7 100644 --- a/frontends/wrap-r/src/pages/xpense/index.tsx +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -5,7 +5,7 @@ import { xpense } from 'common-models' import { Widget } from '../welcome-page' export const XpensePage = Shade< - { initialAccount?: string }, + { accountOwner?: string; accountType?: 'user' | 'organization'; accountName: string }, { selectedAccountName: string accounts: Array<{ @@ -17,10 +17,10 @@ export const XpensePage = Shade< accountNames: string[] } >({ - getInitialState: ({ props }) => ({ + getInitialState: () => ({ accounts: [], accountNames: [], - selectedAccountName: props.initialAccount || '', + selectedAccountName: ``, }), constructed: async ({ injector, updateState, getState }) => { const accounts = await injector.getInstance(XpenseApiService).call({ @@ -44,8 +44,9 @@ export const XpensePage = Shade< return (
-

{selectedAccount?.name}

-

{selectedAccount?.current}

+

+ {selectedAccount?.name} ${selectedAccount?.current.toString()} +

{account.current || 0}
{ + onsubmit={async (ev) => { ev.preventDefault() - injector - .getInstance(XpenseApiService) - .call({ + try { + await injector.getInstance(XpenseApiService).call({ method: 'POST', action: '/:type/:owner/:accountName/replenish', body: { @@ -79,14 +78,17 @@ export const ReplenishPage = Shade< comment: getState().comment, }, }) - .catch((e) => updateState({ error: e })) + history.back() + } catch (e) { + updateState({ error: e }) + } }}> { - updateState({ amount: parseInt((ev.target as any).value as string, 10) }) + updateState({ amount: parseInt((ev.target as any).value as string, 10) }, true) }} required /> @@ -95,7 +97,7 @@ export const ReplenishPage = Shade< labelTitle="Comment" value={getState().comment || ''} onchange={(ev) => { - updateState({ comment: (ev.target as any).value }) + updateState({ comment: (ev.target as any).value }, true) }} /> +
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/components/header.tsx b/frontends/wrap-r/src/pages/xpense/components/header.tsx new file mode 100644 index 00000000..620bdaa9 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/components/header.tsx @@ -0,0 +1,15 @@ +import { Shade, createComponent } from '@furystack/shades' +import { xpense } from 'common-models' + +export const SelectedAccountHeader = Shade({ + render: ({ props }) => { + return ( +
+

+ {props.name} {props.subTitle && props.subTitle} +

+
Balance: {props.current.toString()}
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/index.tsx b/frontends/wrap-r/src/pages/xpense/index.tsx index d7a1cfc7..47abe8d5 100644 --- a/frontends/wrap-r/src/pages/xpense/index.tsx +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -1,93 +1,33 @@ -import { Shade, createComponent, LocationService } from '@furystack/shades' -import { XpenseApiService } from 'common-frontend-utils' -import { Button, Autocomplete, styles } from 'common-components' -import { xpense } from 'common-models' -import { Widget } from '../welcome-page' +import { Shade, createComponent, Router } from '@furystack/shades' +import { styles } from 'common-components' +import { AccountContext } from './account-context' +import { AccountSelector } from './components/account-selector' -export const XpensePage = Shade< - { accountOwner?: string; accountType?: 'user' | 'organization'; accountName: string }, - { - selectedAccountName: string - accounts: Array<{ - name: string - ownerType: xpense.Account['ownerType'] - ownerName: xpense.Account['ownerName'] - current: xpense.Account['current'] - }> - accountNames: string[] - } ->({ - getInitialState: () => ({ - accounts: [], - accountNames: [], - selectedAccountName: ``, - }), - constructed: async ({ injector, updateState, getState }) => { - const accounts = await injector.getInstance(XpenseApiService).call({ - method: 'GET', - action: '/availableAccounts', - }) - updateState({ - accounts, - accountNames: accounts.map((a) => `${a.ownerType}/${a.ownerName}/${a.name}`), - ...(getState().selectedAccountName - ? {} - : { selectedAccountName: `${accounts[0].ownerType}/${accounts[0].ownerName}/${accounts[0].name}` }), - }) - }, - render: ({ getState, injector, updateState }) => { - const { accounts, accountNames, selectedAccountName } = getState() - const [selectedOwnerType, selectedOwnerName, selectedName] = selectedAccountName.split('/') - const selectedAccount = accounts.find( - (ac) => ac.name === selectedName && ac.ownerType === selectedOwnerType && ac.ownerName === selectedOwnerName, - ) +export const XpensePage = Shade<{ accountOwner?: string; accountType?: 'user' | 'organization'; accountName: string }>({ + render: () => { return (
-
-

- {selectedAccount?.name} ${selectedAccount?.current.toString()} -

-
- { - history.pushState({}, '', `/xpense/${account}`) - updateState({ selectedAccountName: account }) - }} - /> - -
- {selectedAccountName ? ( -
- - -
- ) : ( -
...select an account first...
- )} + ( +
+ history.pushState({}, '', `/xpense/${account}`)} /> + Select an account first. +
+ )} + routes={[ + { + routingOptions: { end: false }, + url: '/xpense/:type/:owner/:accountName', + component: ({ match }) => ( + + ), + }, + ]} + />
) }, diff --git a/frontends/wrap-r/src/pages/xpense/replenish.tsx b/frontends/wrap-r/src/pages/xpense/replenish.tsx index a0506aea..41563b31 100644 --- a/frontends/wrap-r/src/pages/xpense/replenish.tsx +++ b/frontends/wrap-r/src/pages/xpense/replenish.tsx @@ -2,37 +2,18 @@ import { Shade, createComponent } from '@furystack/shades' import { styles, Input, Button, colors } from 'common-components' import { XpenseApiService } from 'common-frontend-utils' import { xpense } from 'common-models' -import { Init } from '../init' +import { SelectedAccountHeader } from './components/header' export const ReplenishPage = Shade< - { accountOwner: string; accountType: 'user' | 'organization'; accountName: string }, - { amount: number; comment?: string; error?: Error; account?: xpense.Account } + xpense.Account & { onReplenished: (replenishment: xpense.Replenishment) => void }, + { amount: number; comment?: string; error?: Error } >({ getInitialState: () => ({ amount: 0, }), - constructed: async ({ injector, props, updateState }) => { - try { - const account: xpense.Account = await injector.getInstance(XpenseApiService).call({ - method: 'GET', - action: '/:type/:owner/:accountName', - query: { - accountName: props.accountName, - owner: props.accountOwner, - type: props.accountType, - }, - }) - updateState({ account }) - } catch (error) { - updateState({ error }) - } - }, shadowDomName: 'replenish-page', render: ({ props, getState, updateState, injector }) => { - const { account, error } = getState() - if (!account) { - return - } + const { error } = getState() if (error) { return (

WhoOoOops... 😱

Failed to add to the balance 😓

-

Something went wrong during replenishing the balance for account '{props.accountName}'

+

Something went wrong during replenishing the balance for account '{props.name}'

{JSON.stringify(error)}
Go to home... @@ -62,22 +43,21 @@ export const ReplenishPage = Shade< } return (
-

Replenish the balance for {props.accountName}

-

- The current balance is: {account.current || 0} -

+ { ev.preventDefault() try { - await injector.getInstance(XpenseApiService).call({ + const replenishment = await injector.getInstance(XpenseApiService).call({ method: 'POST', action: '/:type/:owner/:accountName/replenish', + url: { type: props.ownerType, owner: props.ownerName, accountName: props.name }, body: { amount: getState().amount, comment: getState().comment, }, }) + props.onReplenished(replenishment) history.back() } catch (e) { updateState({ error: e }) @@ -100,6 +80,9 @@ export const ReplenishPage = Shade< updateState({ comment: (ev.target as any).value }, true) }} /> + diff --git a/frontends/wrap-r/src/pages/xpense/shopping.tsx b/frontends/wrap-r/src/pages/xpense/shopping.tsx index 785e5c88..1b3fdbfc 100644 --- a/frontends/wrap-r/src/pages/xpense/shopping.tsx +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -1,12 +1,18 @@ import { Shade, createComponent } from '@furystack/shades' +import { styles } from 'common-components' +import { xpense } from 'common-models' -export const XpenseShoppingPage = Shade<{ - accountOwner: string - accountType: 'user' | 'organization' - accountName: string -}>({ +export const XpenseShoppingPage = Shade({ shadowDomName: 'xpense-shopping-page', render: ({ props }) => { - return
Shopping from the account {props.accountName}
+ return ( +
+

+ Shopping - {props.name}{' '} + Balance: ${props.current.toString()} +

+
+
+ ) }, }) diff --git a/services/xpense/src/actions/get-account.ts b/services/xpense/src/actions/get-account.ts index cef525da..cd7b10da 100644 --- a/services/xpense/src/actions/get-account.ts +++ b/services/xpense/src/actions/get-account.ts @@ -3,12 +3,12 @@ import { xpense } from 'common-models' export const GetAccount: RequestAction<{ result: xpense.Account - query: { type: 'user' | 'organization'; owner: string; accountName: string } -}> = async ({ injector, getQuery }) => { - const { accountName, owner, type } = getQuery() + urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } +}> = async ({ injector, getUrlParams }) => { + const { accountName, owner, type } = getUrlParams() const ds = injector.getDataSetFor('accounts') const [account] = await ds.filter(injector, { - filter: { name: accountName, ownerType: type, ownerName: owner }, + filter: { $and: [{ name: accountName }, { ownerType: type }, { ownerName: owner }] }, top: 1, }) if (!account) { diff --git a/services/xpense/src/actions/post-replenishment.ts b/services/xpense/src/actions/post-replenishment.ts index fdc5d3bc..2247bbe7 100644 --- a/services/xpense/src/actions/post-replenishment.ts +++ b/services/xpense/src/actions/post-replenishment.ts @@ -4,12 +4,12 @@ import { HttpUserContext } from '@furystack/rest-service' export const PostReplenishment: RequestAction<{ body: { amount: number; comment?: string } - query: { type: 'user' | 'organization'; owner: string; accountName: string } + urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } result: xpense.Replenishment -}> = async ({ injector, getBody, getQuery }) => { +}> = async ({ injector, getBody, getUrlParams }) => { const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() const body = await getBody() - const { accountName, owner, type } = getQuery() + const { accountName, owner, type } = getUrlParams() const ds = injector.getDataSetFor('accounts') const [account] = await ds.filter(injector, { filter: { name: accountName, ownerType: type, ownerName: owner }, diff --git a/services/xpense/src/actions/post-shopping.ts b/services/xpense/src/actions/post-shopping.ts index 1a1bd2e2..712af44f 100644 --- a/services/xpense/src/actions/post-shopping.ts +++ b/services/xpense/src/actions/post-shopping.ts @@ -4,12 +4,12 @@ import { HttpUserContext } from '@furystack/rest-service' export const PostShopping: RequestAction<{ body: Array<{ itemName: string; amount: number; unitPrice: number }> - query: { type: 'user' | 'organization'; owner: string; accountName: string } + urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } result: xpense.Shopping -}> = async ({ injector, getBody, getQuery }) => { +}> = async ({ injector, getBody, getUrlParams }) => { const currentUser = await injector.getInstance(HttpUserContext).getCurrentUser() const body = await getBody() - const { accountName, owner, type } = getQuery() + const { accountName, owner, type } = getUrlParams() const ds = injector.getDataSetFor('accounts') const [account] = await ds.filter(injector, { filter: { name: accountName, ownerType: type, ownerName: owner }, diff --git a/services/xpense/src/config.ts b/services/xpense/src/config.ts index 58ce4c60..62f6cbb1 100644 --- a/services/xpense/src/config.ts +++ b/services/xpense/src/config.ts @@ -5,7 +5,6 @@ import { ConsoleLogger } from '@furystack/logging' import { Injector } from '@furystack/inject' import { xpense } from 'common-models' import { databases } from 'common-config' -import { HttpUserContext } from '@furystack/rest-service' export const injector = new Injector() @@ -54,14 +53,6 @@ injector.setupRepository((repo) => repo .createDataSet(xpense.Account, { name: 'accounts', - addFilter: async ({ injector: i }) => { - const user = await i.getInstance(HttpUserContext).getCurrentUser() - return { - filter: { - ownerName: user.username, - }, - } - }, }) .createDataSet(xpense.Item, { name: 'items', From e53a4f56ee004b373de9f2da37310594a21ddb97 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 17:55:03 +0200 Subject: [PATCH 09/27] feat(xpense): Shopping V0.2 --- common/components/src/input.tsx | 5 + common/models/src/apis/xpense-api.ts | 2 +- common/models/src/xpense/shopping.ts | 1 + .../pages/organizations/add-organization.tsx | 8 +- .../src/pages/xpense/account-context.tsx | 18 ++- .../src/pages/xpense/account-dashboard.tsx | 2 +- .../src/pages/xpense/components/header.tsx | 16 +- .../xpense/components/shopping-entry.tsx | 107 +++++++++++++ .../wrap-r/src/pages/xpense/replenish.tsx | 2 +- .../wrap-r/src/pages/xpense/shopping.tsx | 143 ++++++++++++++++-- services/xpense/src/actions/post-shopping.ts | 14 +- .../src/services/ensure-items-for-shopping.ts | 44 ++++++ 12 files changed, 329 insertions(+), 33 deletions(-) create mode 100644 frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx create mode 100644 services/xpense/src/services/ensure-items-for-shopping.ts diff --git a/common/components/src/input.tsx b/common/components/src/input.tsx index 3904f50d..8f7c03c3 100644 --- a/common/components/src/input.tsx +++ b/common/components/src/input.tsx @@ -3,6 +3,7 @@ import { promisifyAnimation } from 'common-frontend-utils' import { colors } from './styles' export interface InputProps extends PartialElement { + onTextChange?: (text: string) => void labelTitle?: string multiLine?: false } @@ -47,6 +48,10 @@ export const Input = Shade({
) : ( { + props.onTextChange && props.onTextChange((ev.target as any).value) + props.onchange && (props.onchange as any)(ev) + }} onfocus={() => { if (!props.disabled) { promisifyAnimation( diff --git a/common/models/src/apis/xpense-api.ts b/common/models/src/apis/xpense-api.ts index de773982..85685497 100644 --- a/common/models/src/apis/xpense-api.ts +++ b/common/models/src/apis/xpense-api.ts @@ -28,7 +28,7 @@ export interface XpenseApi extends RestApi { result: Replenishment }> '/:type/:owner/:accountName/shop': RequestAction<{ - body: Array<{ itemName: string; amount: number; unitPrice: number }> + body: { shopName: string; entries: Array<{ itemName: string; amount: number; unitPrice: number }> } urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } result: Shopping }> diff --git a/common/models/src/xpense/shopping.ts b/common/models/src/xpense/shopping.ts index 8784a26b..893e9bcc 100644 --- a/common/models/src/xpense/shopping.ts +++ b/common/models/src/xpense/shopping.ts @@ -4,5 +4,6 @@ export class Shopping { creationDate!: string entries!: Array<{ itemName: string; amount: number; unitPrice: number }> sumAmount!: number + shopName!: string createdBy!: string } diff --git a/frontends/wrap-r/src/pages/organizations/add-organization.tsx b/frontends/wrap-r/src/pages/organizations/add-organization.tsx index acb07e81..6de94c56 100644 --- a/frontends/wrap-r/src/pages/organizations/add-organization.tsx +++ b/frontends/wrap-r/src/pages/organizations/add-organization.tsx @@ -19,15 +19,11 @@ export const AddOrganizationPage = Shade<{}, Omit>({ }) history.pushState({}, '', `/organization/${encodeURIComponent(created.name)}`) }}> - updateState({ name: (ev.target as any).value }, true)} - /> + updateState({ name: value }, true)} /> updateState({ description: (ev.target as any).value }, true)} + onTextChange={(value) => updateState({ description: value }, true)} /> + +
+
) }, diff --git a/services/xpense/src/actions/post-shopping.ts b/services/xpense/src/actions/post-shopping.ts index 712af44f..76781cc5 100644 --- a/services/xpense/src/actions/post-shopping.ts +++ b/services/xpense/src/actions/post-shopping.ts @@ -1,9 +1,10 @@ import { RequestAction, JsonResult, RequestError } from '@furystack/rest' import { xpense } from 'common-models' import { HttpUserContext } from '@furystack/rest-service' +import { ensureItemsForShopping } from '../services/ensure-items-for-shopping' export const PostShopping: RequestAction<{ - body: Array<{ itemName: string; amount: number; unitPrice: number }> + body: { shopName: string; entries: Array<{ itemName: string; amount: number; unitPrice: number }> } urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } result: xpense.Shopping }> = async ({ injector, getBody, getUrlParams }) => { @@ -21,15 +22,18 @@ export const PostShopping: RequestAction<{ const createdShopping = await injector.getDataSetFor('shoppings').add(injector, { createdBy: currentUser.username, - entries: body, + entries: body.entries, creationDate: new Date().toISOString(), accountId: account._id, - sumAmount: body.reduce((last, current) => last + current.amount, 0), - _id: '', - }) + sumAmount: body.entries.reduce((last, current) => last + current.unitPrice * current.amount, 0), + shopName: body.shopName, + } as xpense.Shopping) + + ensureItemsForShopping({ injector, shopping: createdShopping }) await ds.update(injector, account._id, { ...account, + current: account.current - createdShopping.sumAmount, history: [ ...account.history, { diff --git a/services/xpense/src/services/ensure-items-for-shopping.ts b/services/xpense/src/services/ensure-items-for-shopping.ts new file mode 100644 index 00000000..0fa44b04 --- /dev/null +++ b/services/xpense/src/services/ensure-items-for-shopping.ts @@ -0,0 +1,44 @@ +import { Injector } from '@furystack/inject' +import { xpense } from 'common-models' +import { StoreManager } from '@furystack/core' +import { MongodbStore } from '@furystack/mongodb-store' +import { ObjectId } from 'mongodb' + +export const ensureItemsForShopping = async ({ + injector, + shopping, +}: { + injector: Injector + shopping: xpense.Shopping +}) => { + const logger = injector.logger.withScope('xpense/services/ensureItemsForShopping') + const shopStore: MongodbStore = injector.getInstance(StoreManager).getStoreFor(xpense.Shop) + const itemStore: MongodbStore = injector.getInstance(StoreManager).getStoreFor(xpense.Item) + + const shopCollection = await shopStore.getCollection() + const itemCollection = await itemStore.getCollection() + + const shop = await shopCollection.findOne({ name: shopping.shopName }) + if (!shop) { + logger.information({ message: `Shop '${shopping.shopName}' does not exists, adding...` }) + await shopCollection.insertOne({ + name: shopping.shopName, + creationDate: new Date().toISOString(), + createdBy: shopping.createdBy, + _id: new ObjectId().toHexString(), + } as any) + } + + for (const entry of shopping.entries) { + const existing = await itemCollection.findOne({ name: entry.itemName }) + if (!existing) { + logger.information({ message: `Entry '${entry.itemName}' does not exists, adding...` }) + await itemCollection.insertOne({ + creationDate: new Date().toISOString(), + createdBy: shopping.createdBy, + _id: new ObjectId().toHexString(), + name: entry.itemName, + }) + } + } +} From 8328d73eab0635c77a532de4f8e5c3f832d989be Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 19:53:15 +0200 Subject: [PATCH 10/27] chore(xpense ui): minor form fixes, added delete, added new row on shop select --- .../src/pages/xpense/components/shopping-entry.tsx | 10 ++++++++-- frontends/wrap-r/src/pages/xpense/shopping.tsx | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx index 0526b501..35b60914 100644 --- a/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx +++ b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx @@ -1,5 +1,5 @@ import { Shade, createComponent } from '@furystack/shades' -import { Input, Autocomplete } from 'common-components' +import { Input, Autocomplete, Button } from 'common-components' import { xpense } from 'common-models' export interface ShoppingEntry { @@ -43,7 +43,7 @@ export const ShoppingEntryRow = Shade({ return (
i.name)} onchange={(value) => { props.onChange({ ...props.entry, name: value }) @@ -54,6 +54,7 @@ export const ShoppingEntryRow = Shade({ labelTitle="Unit price" name="unitPrice" value={props.entry.unitPrice.toString()} + required onTextChange={(value) => { const valueNo = parseFloat(value) const totalPrice = valueNo * getAmount() @@ -72,6 +73,7 @@ export const ShoppingEntryRow = Shade({ name="amount" value={props.entry.amount.toString()} min="1" + required onTextChange={(value) => { const amountNo = parseFloat(value) const unitPrice = getUnitPrice() @@ -91,6 +93,7 @@ export const ShoppingEntryRow = Shade({ type="number" labelTitle="Total Price" value={props.entry.totalPrice.toString()} + required onTextChange={(value) => { const totalPriceNo = parseFloat(value) const unitPrice = totalPriceNo / getAmount() @@ -101,6 +104,9 @@ export const ShoppingEntryRow = Shade({ }) }} /> +
) }, diff --git a/frontends/wrap-r/src/pages/xpense/shopping.tsx b/frontends/wrap-r/src/pages/xpense/shopping.tsx index b032d1e5..288e813a 100644 --- a/frontends/wrap-r/src/pages/xpense/shopping.tsx +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -94,9 +94,12 @@ export const XpenseShoppingPage = Shade< suggestions={props.shops.map((s) => s.name)} onchange={(shopName) => { updateState({ shopName }) + if (getState().entries.length < 1) { + updateState({ entries: [{ name: '', amount: 1, totalPrice: 0, unitPrice: 0 }] }) + } }} /> -
+
{getState().entries.map((entry, index) => ( Date: Mon, 6 Apr 2020 19:54:26 +0200 Subject: [PATCH 11/27] chore(package): updated dependencies --- services/xpense/package.json | 5 +++ yarn.lock | 77 ++++++++++++++++++------------------ 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/services/xpense/package.json b/services/xpense/package.json index 002881f6..db5e2bc1 100644 --- a/services/xpense/package.json +++ b/services/xpense/package.json @@ -23,5 +23,10 @@ "@furystack/rest-service": "^1.1.1", "common-service-utils": "1.0.0", "tslib": "^1.10.0" + }, + "optionalDependencies": { + "@typescript-eslint/eslint-plugin": "^2.21.0", + "@typescript-eslint/parser": "^2.18.0", + "prettier": "^2.0.1" } } diff --git a/yarn.lock b/yarn.lock index 4fb3549c..031ef6ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1427,39 +1427,39 @@ source-map "^0.6.0" "@typescript-eslint/eslint-plugin@^2.21.0": - version "2.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.26.0.tgz#04c96560c8981421e5a9caad8394192363cc423f" - integrity sha512-4yUnLv40bzfzsXcTAtZyTjbiGUXMrcIJcIMioI22tSOyAxpdXiZ4r7YQUU8Jj6XXrLz9d5aMHPQf5JFR7h27Nw== + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.27.0.tgz#e479cdc4c9cf46f96b4c287755733311b0d0ba4b" + integrity sha512-/my+vVHRN7zYgcp0n4z5A6HAK7bvKGBiswaM5zIlOQczsxj/aiD7RcgD+dvVFuwFaGh5+kM7XA6Q6PN0bvb1tw== dependencies: - "@typescript-eslint/experimental-utils" "2.26.0" + "@typescript-eslint/experimental-utils" "2.27.0" functional-red-black-tree "^1.0.1" regexpp "^3.0.0" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@2.26.0": - version "2.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.26.0.tgz#063390c404d9980767d76274df386c0aa675d91d" - integrity sha512-RELVoH5EYd+JlGprEyojUv9HeKcZqF7nZUGSblyAw1FwOGNnmQIU8kxJ69fttQvEwCsX5D6ECJT8GTozxrDKVQ== +"@typescript-eslint/experimental-utils@2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.27.0.tgz#801a952c10b58e486c9a0b36cf21e2aab1e9e01a" + integrity sha512-vOsYzjwJlY6E0NJRXPTeCGqjv5OHgRU1kzxHKWJVPjDYGbPgLudBXjIlc+OD1hDBZ4l1DLbOc5VjofKahsu9Jw== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/typescript-estree" "2.26.0" + "@typescript-eslint/typescript-estree" "2.27.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" "@typescript-eslint/parser@^2.18.0": - version "2.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.26.0.tgz#385463615818b33acb72a25b39c03579df93d76f" - integrity sha512-+Xj5fucDtdKEVGSh9353wcnseMRkPpEAOY96EEenN7kJVrLqy/EVwtIh3mxcUz8lsFXW1mT5nN5vvEam/a5HiQ== + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.27.0.tgz#d91664335b2c46584294e42eb4ff35838c427287" + integrity sha512-HFUXZY+EdwrJXZo31DW4IS1ujQW3krzlRjBrFRrJcMDh0zCu107/nRfhk/uBasO8m0NVDbBF5WZKcIUMRO7vPg== dependencies: "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "2.26.0" - "@typescript-eslint/typescript-estree" "2.26.0" + "@typescript-eslint/experimental-utils" "2.27.0" + "@typescript-eslint/typescript-estree" "2.27.0" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/typescript-estree@2.26.0": - version "2.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.26.0.tgz#d8132cf1ee8a72234f996519a47d8a9118b57d56" - integrity sha512-3x4SyZCLB4zsKsjuhxDLeVJN6W29VwBnYpCsZ7vIdPel9ZqLfIZJgJXO47MNUkurGpQuIBALdPQKtsSnWpE1Yg== +"@typescript-eslint/typescript-estree@2.27.0": + version "2.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.27.0.tgz#a288e54605412da8b81f1660b56c8b2e42966ce8" + integrity sha512-t2miCCJIb/FU8yArjAvxllxbTiyNqaXJag7UOpB5DVoM3+xnjeOngtqlJkLRnMtzaRcJhe3CIR9RmL40omubhg== dependencies: debug "^4.1.1" eslint-visitor-keys "^1.1.0" @@ -2902,22 +2902,23 @@ crypto-browserify@^3.11.0: randomfill "^1.0.3" css-loader@^3.2.0: - version "3.4.2" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.4.2.tgz#d3fdb3358b43f233b78501c5ed7b1c6da6133202" - integrity sha512-jYq4zdZT0oS0Iykt+fqnzVLRIeiPWhka+7BqPn+oSIpWJAHak5tmB/WZrJ2a21JhCeFyNnnlroSl8c+MtVndzA== + version "3.5.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.5.0.tgz#8c68736fbcba1a9b92a288277ba8998b709ac3ae" + integrity sha512-zed7D7JNZEq7htpu3H9oBUVWVgI6s8FgigejbVq+dc5zHV3SUPsyYBozXLIC9Eb73ahAYmnVdnn/SAB4WA75AQ== dependencies: camelcase "^5.3.1" cssesc "^3.0.0" icss-utils "^4.1.1" loader-utils "^1.2.3" normalize-path "^3.0.0" - postcss "^7.0.23" + postcss "^7.0.27" postcss-modules-extract-imports "^2.0.0" postcss-modules-local-by-default "^3.0.2" - postcss-modules-scope "^2.1.1" + postcss-modules-scope "^2.2.0" postcss-modules-values "^3.0.0" - postcss-value-parser "^4.0.2" - schema-utils "^2.6.0" + postcss-value-parser "^4.0.3" + schema-utils "^2.6.5" + semver "^6.3.0" css-select@^1.1.0: version "1.2.0" @@ -6770,10 +6771,10 @@ postcss-modules-local-by-default@^3.0.2: postcss-selector-parser "^6.0.2" postcss-value-parser "^4.0.0" -postcss-modules-scope@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.1.tgz#33d4fc946602eb5e9355c4165d68a10727689dba" - integrity sha512-OXRUPecnHCg8b9xWvldG/jUpRIGPNRka0r4D4j0ESUU2/5IOnpsjfPPmDprM3Ih8CgZ8FXjWqaniK5v4rWt3oQ== +postcss-modules-scope@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" + integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== dependencies: postcss "^7.0.6" postcss-selector-parser "^6.0.0" @@ -6795,12 +6796,12 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: indexes-of "^1.0.1" uniq "^1.0.1" -postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2: +postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== -postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.23, postcss@^7.0.5, postcss@^7.0.6: +postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.27, postcss@^7.0.5, postcss@^7.0.6: version "7.0.27" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== @@ -6832,9 +6833,9 @@ prettier@^1.16.4: integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== prettier@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.3.tgz#9a06f0e94a51420e78b6925568b5bec72afe41ea" - integrity sha512-5qpBDBHO9fpE0zruKiTZm8Gxmz7kknO+WlQR/ivV+RMwgDw/WjOgmxLDn66MPrxq/WZPx/EgEZzh87xJO5E6Fw== + version "2.0.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.4.tgz#2d1bae173e355996ee355ec9830a7a1ee05457ef" + integrity sha512-SVJIQ51spzFDvh4fIbCLvciiDMCrRhlN3mbZvv/+ycjvmF5E73bKdGfU8QDLNmjYJf+lsGnDBC4UUnvTe5OO0w== pretty-bytes@5.3.0: version "5.3.0" @@ -7158,9 +7159,9 @@ regexpp@^2.0.1: integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== regexpp@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.0.0.tgz#dd63982ee3300e67b41c1956f850aa680d9d330e" - integrity sha512-Z+hNr7RAVWxznLPuA7DIh8UNX1j9CDrUQxskw9IrBE1Dxue2lyXT+shqEIeLUjrokxIP8CMy1WkjgG3rTsd5/g== + version "3.1.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" + integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== regexpu-core@^4.7.0: version "4.7.0" @@ -7447,7 +7448,7 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -schema-utils@^2.6.0, schema-utils@^2.6.4, schema-utils@^2.6.5: +schema-utils@^2.6.4, schema-utils@^2.6.5: version "2.6.5" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.5.tgz#c758f0a7e624263073d396e29cd40aa101152d8a" integrity sha512-5KXuwKziQrTVHh8j/Uxz+QUbxkaLW9X/86NBlx/gnKgtsZA2GIVMUn17qWhRFwF8jdYb3Dig5hRO/W5mZqy6SQ== From 9b9a751d5d33f86119b9804f9da4a5db73bfda43 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 19:55:52 +0200 Subject: [PATCH 12/27] feat(nginx): added xpense to config --- nginx.conf | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nginx.conf b/nginx.conf index c97ef65d..0bf538fa 100644 --- a/nginx.conf +++ b/nginx.conf @@ -125,6 +125,22 @@ http { proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; } + + # reverse proxy + location /api/xpense { + proxy_pass http://127.0.0.1:9092/api/xpense; + proxy_http_version 1.1; + proxy_cache_bypass $http_upgrade; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } # gzip gzip on; From e909bf6d0f55bb14f29ae3800ab421bf1a3dfdf0 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 20:16:50 +0200 Subject: [PATCH 13/27] fix(wrap-r-frontend): xpense env vars --- frontends/wrap-r/webpack.common.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontends/wrap-r/webpack.common.js b/frontends/wrap-r/webpack.common.js index 26cb0b38..7919aa63 100644 --- a/frontends/wrap-r/webpack.common.js +++ b/frontends/wrap-r/webpack.common.js @@ -20,6 +20,9 @@ module.exports = { WRAPPR_SERVICE_EXTENRAL_URL: process.env.WRAPPR_SERVICE_EXTENRAL_URL || sites.services['wrap-r'].externalPath, LOGGR_SERVICE_INTERNAL_PORT: process.env.LOGGR_SERVICE_INTERNAL_PORT || sites.services['logg-r'].internalPort, LOGGR_SERVICE_EXTENAL_URL: process.env.LOGGR_SERVICE_EXTENAL_URL || sites.services['logg-r'].externalPath, + XPENSE_SERVICE_INTERNAL_PORT: process.env.XPENSE_SERVICE_INTERNAL_PORT || sites.services.xpense.internalPort, + XPENSE_SERVICE_EXTENAL_URL: process.env.XPENSE_SERVICE_EXTENAL_URL || sites.services.xpense.externalPath, + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID || tokens.githubClientId, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET || tokens.githubClientSecret, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || tokens.googleClientId, From ea909ccaa7431e8e9b6214d2bfcb5fd3bda7aa9b Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 20:31:23 +0200 Subject: [PATCH 14/27] fix(wrap-r-frontend): typo in env.var --- frontends/wrap-r/webpack.common.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/wrap-r/webpack.common.js b/frontends/wrap-r/webpack.common.js index 7919aa63..15881f72 100644 --- a/frontends/wrap-r/webpack.common.js +++ b/frontends/wrap-r/webpack.common.js @@ -21,7 +21,7 @@ module.exports = { LOGGR_SERVICE_INTERNAL_PORT: process.env.LOGGR_SERVICE_INTERNAL_PORT || sites.services['logg-r'].internalPort, LOGGR_SERVICE_EXTENAL_URL: process.env.LOGGR_SERVICE_EXTENAL_URL || sites.services['logg-r'].externalPath, XPENSE_SERVICE_INTERNAL_PORT: process.env.XPENSE_SERVICE_INTERNAL_PORT || sites.services.xpense.internalPort, - XPENSE_SERVICE_EXTENAL_URL: process.env.XPENSE_SERVICE_EXTENAL_URL || sites.services.xpense.externalPath, + XPENSE_SERVICE_EXTERNAL_URL: process.env.XPENSE_SERVICE_EXTERNAL_URL || sites.services.xpense.externalPath, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID || tokens.githubClientId, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET || tokens.githubClientSecret, From b8dfc2e7c28a53febb3bce061d7a0ed37c048afd Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 20:34:52 +0200 Subject: [PATCH 15/27] fix(config): more env var typos --- common/config/src/sites.ts | 6 +++--- frontends/wrap-r/webpack.common.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/config/src/sites.ts b/common/config/src/sites.ts index e04059a1..d0ec3c73 100644 --- a/common/config/src/sites.ts +++ b/common/config/src/sites.ts @@ -2,15 +2,15 @@ export const sites = { services: { 'wrap-r': { internalPort: process.env.WRAPPR_SERVICE_INTERNAL_PORT || 9090, - externalPath: process.env.WRAPPR_SERVICE_EXTENRAL_URL || 'http://localhost:9090/api', + externalPath: process.env.WRAPPR_SERVICE_EXTERNAL_URL || 'http://localhost:9090/api', }, 'logg-r': { internalPort: process.env.LOGGR_SERVICE_INTERNAL_PORT || 9091, - externalPath: process.env.LOGGR_SERVICE_EXTENAL_URL || 'http://localhost:9091/api', + externalPath: process.env.LOGGR_SERVICE_EXTERNAL_URL || 'http://localhost:9091/api', }, xpense: { internalPort: process.env.XPENSE_SERVICE_INTERNAL_PORT || 9092, - externalPath: process.env.XPENSE_SERVICE_EXTENAL_URL || 'http://localhost:9092/api', + externalPath: process.env.XPENSE_SERVICE_EXTERNAL_URL || 'http://localhost:9092/api', }, }, frontends: { diff --git a/frontends/wrap-r/webpack.common.js b/frontends/wrap-r/webpack.common.js index 15881f72..76fbef3f 100644 --- a/frontends/wrap-r/webpack.common.js +++ b/frontends/wrap-r/webpack.common.js @@ -17,9 +17,9 @@ module.exports = { APP_VERSION: require('./package.json').version, BUILD_DATE: new Date().toISOString(), WRAPPR_SERVICE_INTERNAL_PORT: process.env.WRAPPR_SERVICE_INTERNAL_PORT || sites.services['wrap-r'].internalPort, - WRAPPR_SERVICE_EXTENRAL_URL: process.env.WRAPPR_SERVICE_EXTENRAL_URL || sites.services['wrap-r'].externalPath, + WRAPPR_SERVICE_EXTERNAL_URL: process.env.WRAPPR_SERVICE_EXTERNAL_URL || sites.services['wrap-r'].externalPath, LOGGR_SERVICE_INTERNAL_PORT: process.env.LOGGR_SERVICE_INTERNAL_PORT || sites.services['logg-r'].internalPort, - LOGGR_SERVICE_EXTENAL_URL: process.env.LOGGR_SERVICE_EXTENAL_URL || sites.services['logg-r'].externalPath, + LOGGR_SERVICE_EXTERNAL_URL: process.env.LOGGR_SERVICE_EXTERNAL_URL || sites.services['logg-r'].externalPath, XPENSE_SERVICE_INTERNAL_PORT: process.env.XPENSE_SERVICE_INTERNAL_PORT || sites.services.xpense.internalPort, XPENSE_SERVICE_EXTERNAL_URL: process.env.XPENSE_SERVICE_EXTERNAL_URL || sites.services.xpense.externalPath, From 47790ac509b71e4b087316b4c32f14ca66756838 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Mon, 6 Apr 2020 21:01:40 +0200 Subject: [PATCH 16/27] cleanup(wrap-r-frontend): simplified geting from env.vars --- frontends/wrap-r/webpack.common.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontends/wrap-r/webpack.common.js b/frontends/wrap-r/webpack.common.js index 76fbef3f..af1c6a28 100644 --- a/frontends/wrap-r/webpack.common.js +++ b/frontends/wrap-r/webpack.common.js @@ -16,13 +16,12 @@ module.exports = { new webpack.EnvironmentPlugin({ APP_VERSION: require('./package.json').version, BUILD_DATE: new Date().toISOString(), - WRAPPR_SERVICE_INTERNAL_PORT: process.env.WRAPPR_SERVICE_INTERNAL_PORT || sites.services['wrap-r'].internalPort, - WRAPPR_SERVICE_EXTERNAL_URL: process.env.WRAPPR_SERVICE_EXTERNAL_URL || sites.services['wrap-r'].externalPath, - LOGGR_SERVICE_INTERNAL_PORT: process.env.LOGGR_SERVICE_INTERNAL_PORT || sites.services['logg-r'].internalPort, - LOGGR_SERVICE_EXTERNAL_URL: process.env.LOGGR_SERVICE_EXTERNAL_URL || sites.services['logg-r'].externalPath, - XPENSE_SERVICE_INTERNAL_PORT: process.env.XPENSE_SERVICE_INTERNAL_PORT || sites.services.xpense.internalPort, - XPENSE_SERVICE_EXTERNAL_URL: process.env.XPENSE_SERVICE_EXTERNAL_URL || sites.services.xpense.externalPath, - + WRAPPR_SERVICE_INTERNAL_PORT: sites.services['wrap-r'].internalPort, + WRAPPR_SERVICE_EXTERNAL_URL: sites.services['wrap-r'].externalPath, + LOGGR_SERVICE_INTERNAL_PORT: sites.services['logg-r'].internalPort, + LOGGR_SERVICE_EXTERNAL_URL: sites.services['logg-r'].externalPath, + XPENSE_SERVICE_INTERNAL_PORT: sites.services.xpense.internalPort, + XPENSE_SERVICE_EXTERNAL_URL: sites.services.xpense.externalPath, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID || tokens.githubClientId, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET || tokens.githubClientSecret, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || tokens.googleClientId, From e93ab04e12a0a36ad7ba1082332dc7fa2e7d613b Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Tue, 7 Apr 2020 07:39:41 +0200 Subject: [PATCH 17/27] fix(xpense-ui):remove entry, update balance after shopping, update total price, etc... --- frontends/wrap-r/src/pages/profile.tsx | 11 +++--- .../src/pages/xpense/account-context.tsx | 12 +++++- .../xpense/components/shopping-entry.tsx | 3 +- frontends/wrap-r/src/pages/xpense/index.tsx | 2 +- .../wrap-r/src/pages/xpense/shopping.tsx | 39 ++++++++++++------- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/frontends/wrap-r/src/pages/profile.tsx b/frontends/wrap-r/src/pages/profile.tsx index 5744e564..7da639fd 100644 --- a/frontends/wrap-r/src/pages/profile.tsx +++ b/frontends/wrap-r/src/pages/profile.tsx @@ -19,12 +19,11 @@ export const ProfilePage = Shade< constructed: async ({ injector, updateState }) => { const currentUser = injector.getInstance(SessionService).currentUser.getValue() as User const api = injector.getInstance(WrapRApiService) - const profile = - (await api.call({ - method: 'GET', - action: '/profiles/:username', - url: { username: currentUser.username }, - })) as Profile + const profile = (await api.call({ + method: 'GET', + action: '/profiles/:username', + url: { username: currentUser.username }, + })) as Profile const loginProviderDetails = await api.call({ method: 'GET', action: '/loginProviderDetails', diff --git a/frontends/wrap-r/src/pages/xpense/account-context.tsx b/frontends/wrap-r/src/pages/xpense/account-context.tsx index 22b5c4e0..ac505e83 100644 --- a/frontends/wrap-r/src/pages/xpense/account-context.tsx +++ b/frontends/wrap-r/src/pages/xpense/account-context.tsx @@ -11,6 +11,7 @@ export const AccountContext = Shade< { type: 'user' | 'organization'; accountName: string; owner: string }, { account?: xpense.Account; items: xpense.Item[]; shops: xpense.Shop[]; isLoading: boolean; error?: Error } >({ + shadowDomName: 'xpense-account-context', getInitialState: () => ({ isLoading: true, items: [], @@ -133,7 +134,16 @@ export const AccountContext = Shade< }, { url: '/xpense/:type/:owner/:accountName/shopping', - component: () => , + component: () => ( + { + updateState({ account: { ...account, current: account?.current - total } }) + }} + /> + ), }, { url: '/', routingOptions: { end: false }, component: () => }, ]} diff --git a/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx index 35b60914..996d36ec 100644 --- a/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx +++ b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx @@ -71,8 +71,9 @@ export const ShoppingEntryRow = Shade({ type="number" labelTitle="Amount" name="amount" + step="any" value={props.entry.amount.toString()} - min="1" + min="0" required onTextChange={(value) => { const amountNo = parseFloat(value) diff --git a/frontends/wrap-r/src/pages/xpense/index.tsx b/frontends/wrap-r/src/pages/xpense/index.tsx index 47abe8d5..0dd1dba4 100644 --- a/frontends/wrap-r/src/pages/xpense/index.tsx +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -6,7 +6,7 @@ import { AccountSelector } from './components/account-selector' export const XpensePage = Shade<{ accountOwner?: string; accountType?: 'user' | 'organization'; accountName: string }>({ render: () => { return ( -
+
(
diff --git a/frontends/wrap-r/src/pages/xpense/shopping.tsx b/frontends/wrap-r/src/pages/xpense/shopping.tsx index 288e813a..6bf8474c 100644 --- a/frontends/wrap-r/src/pages/xpense/shopping.tsx +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -8,7 +8,7 @@ import { SelectedAccountHeader } from './components/header' import { ShoppingEntry, ShoppingEntryRow } from './components/shopping-entry' export const XpenseShoppingPage = Shade< - xpense.Account & { shops: xpense.Shop[]; items: xpense.Item[] }, + xpense.Account & { shops: xpense.Shop[]; items: xpense.Item[]; onShopped: (total: number) => void }, { entries: ShoppingEntry[]; shopName: string; error?: Error; isSaveInProgress?: boolean } >({ shadowDomName: 'xpense-shopping-page', @@ -21,8 +21,9 @@ export const XpenseShoppingPage = Shade< const updateTotal = () => { const { entries } = getState() - const total = entries.reduce((last, current) => last + current.totalPrice, 0) + const total = entries.reduce((last, current) => last + (current.totalPrice || 0), 0) ;(element.querySelector('#total') as HTMLInputElement).value = total.toString() + return total } if (isSaveInProgress) { @@ -58,7 +59,7 @@ export const XpenseShoppingPage = Shade< } return ( -
+
@@ -67,6 +68,16 @@ export const XpenseShoppingPage = Shade<
{ ev.preventDefault() + const total = updateTotal() + if ( + !confirm( + `Are you sure to save your shopping? \r\n The total amount is '${total}'. The account balance is ${ + props.current + }. The balance will be ${props.current - total} after submitting.`, + ) + ) { + return + } try { updateState({ isSaveInProgress: true }) await injector.getInstance(XpenseApiService).call({ @@ -82,6 +93,7 @@ export const XpenseShoppingPage = Shade< })), // as Array<{ itemName: string; amount: number; unitPrice: number }>, }, }) + props.onShopped(total) history.pushState({}, '', `/xpense/${props.ownerType}/${props.ownerName}/${props.name}`) } catch (e) { updateState({ error: e }) @@ -104,14 +116,12 @@ export const XpenseShoppingPage = Shade< - updateState( - { - entries: getState().entries.filter((e) => e !== deleted), - }, - true, - ) - } + onRemove={() => { + updateState({ + entries: getState().entries.filter((e) => e !== getState().entries[index]), + }) + updateTotal() + }} onChange={(change) => { const { entries } = getState() entries[index] = change @@ -121,7 +131,7 @@ export const XpenseShoppingPage = Shade< /> ))}
-
+
From f88453f5addc1e5b301a4a2987ad434b5b91e9bd Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Tue, 7 Apr 2020 09:40:50 +0200 Subject: [PATCH 18/27] feat(xpense-ui): Account balance history chart v1 --- frontends/wrap-r/package.json | 1 + .../src/pages/xpense/account-context.tsx | 30 +- .../src/pages/xpense/account-dashboard.tsx | 7 + .../src/pages/xpense/account-history.tsx | 37 + .../wrap-r/src/pages/xpense/shopping.tsx | 6 +- lib/chart.js/Chart.bundle.js | 20991 ++++++++++++++++ lib/chart.js/Chart.bundle.min.js | 11419 +++++++++ lib/chart.js/package.json | 5 + package.json | 3 +- yarn.lock | 9 +- 10 files changed, 32501 insertions(+), 7 deletions(-) create mode 100644 frontends/wrap-r/src/pages/xpense/account-history.tsx create mode 100644 lib/chart.js/Chart.bundle.js create mode 100644 lib/chart.js/Chart.bundle.min.js create mode 100644 lib/chart.js/package.json diff --git a/frontends/wrap-r/package.json b/frontends/wrap-r/package.json index 51494eab..cf13d4a2 100644 --- a/frontends/wrap-r/package.json +++ b/frontends/wrap-r/package.json @@ -34,6 +34,7 @@ "@furystack/rest": "^1.1.1", "@furystack/rest-client-fetch": "^1.1.2", "@furystack/shades": "^3.0.0", + "@types/chart.js": "^2.9.18", "mini-css-extract-plugin": "^0.9.0", "path-to-regexp": "^6.1.0" } diff --git a/frontends/wrap-r/src/pages/xpense/account-context.tsx b/frontends/wrap-r/src/pages/xpense/account-context.tsx index ac505e83..718a2368 100644 --- a/frontends/wrap-r/src/pages/xpense/account-context.tsx +++ b/frontends/wrap-r/src/pages/xpense/account-context.tsx @@ -6,6 +6,7 @@ import { Init } from '../init' import { AccountDashboard } from './account-dashboard' import { ReplenishPage } from './replenish' import { XpenseShoppingPage } from './shopping' +import { AccountHistory } from './account-history' export const AccountContext = Shade< { type: 'user' | 'organization'; accountName: string; owner: string }, @@ -139,12 +140,37 @@ export const AccountContext = Shade< {...account} shops={shops} items={items} - onShopped={(total) => { - updateState({ account: { ...account, current: account?.current - total } }) + onShopped={(shopping) => { + const currentBalance = account.current - shopping.sumAmount + updateState({ + account: { + ...account, + current: currentBalance, + history: [ + ...account.history, + { + balance: currentBalance, + change: -shopping.sumAmount, + date: shopping.creationDate, + relatedEntry: { shoppingId: shopping._id }, + }, + ], + }, + }) }} /> ), }, + { + url: '/xpense/:type/:owner/:accountName/history', + component: () => ( + + ), + }, { url: '/', routingOptions: { end: false }, component: () => }, ]} /> diff --git a/frontends/wrap-r/src/pages/xpense/account-dashboard.tsx b/frontends/wrap-r/src/pages/xpense/account-dashboard.tsx index cd4936e2..48a15369 100644 --- a/frontends/wrap-r/src/pages/xpense/account-dashboard.tsx +++ b/frontends/wrap-r/src/pages/xpense/account-dashboard.tsx @@ -30,6 +30,13 @@ export const AccountDashboard = Shade({ description={`Add to the balance of ${props.name}`} url={`/xpense/${accountUriFragment}/shopping`} /> +
) diff --git a/frontends/wrap-r/src/pages/xpense/account-history.tsx b/frontends/wrap-r/src/pages/xpense/account-history.tsx new file mode 100644 index 00000000..ee2ed279 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/account-history.tsx @@ -0,0 +1,37 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Chart, ChartPoint } from 'chart.js' +import { xpense } from 'common-models' +import { colors, Button } from 'common-components/src' + +export const AccountHistory = Shade<{ account: xpense.Account }>({ + shadowDomName: 'xpense-account-history-page', + onAttach: ({ element, props }) => { + setTimeout(() => { + const canvas = element.querySelector('canvas') as HTMLCanvasElement + new Chart(canvas, { + type: 'line', + data: { + labels: props.account.history.map((h) => [h.date]), + datasets: [ + { + label: 'Balance', + data: props.account.history.map((h) => ({ y: h.balance, t: new Date(h.date) } as ChartPoint)), + borderColor: colors.primary.main, + backgroundColor: colors.primary.dark, + }, + ], + }, + options: {}, + }) + }, 100) + }, + render: () => { + return ( +
+ Account history + + +
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/shopping.tsx b/frontends/wrap-r/src/pages/xpense/shopping.tsx index 6bf8474c..75b1387a 100644 --- a/frontends/wrap-r/src/pages/xpense/shopping.tsx +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -8,7 +8,7 @@ import { SelectedAccountHeader } from './components/header' import { ShoppingEntry, ShoppingEntryRow } from './components/shopping-entry' export const XpenseShoppingPage = Shade< - xpense.Account & { shops: xpense.Shop[]; items: xpense.Item[]; onShopped: (total: number) => void }, + xpense.Account & { shops: xpense.Shop[]; items: xpense.Item[]; onShopped: (total: xpense.Shopping) => void }, { entries: ShoppingEntry[]; shopName: string; error?: Error; isSaveInProgress?: boolean } >({ shadowDomName: 'xpense-shopping-page', @@ -80,7 +80,7 @@ export const XpenseShoppingPage = Shade< } try { updateState({ isSaveInProgress: true }) - await injector.getInstance(XpenseApiService).call({ + const shopping = await injector.getInstance(XpenseApiService).call({ method: 'POST', action: '/:type/:owner/:accountName/shop', url: { type: props.ownerType, owner: props.ownerName, accountName: props.name }, @@ -93,7 +93,7 @@ export const XpenseShoppingPage = Shade< })), // as Array<{ itemName: string; amount: number; unitPrice: number }>, }, }) - props.onShopped(total) + props.onShopped(shopping) history.pushState({}, '', `/xpense/${props.ownerType}/${props.ownerName}/${props.name}`) } catch (e) { updateState({ error: e }) diff --git a/lib/chart.js/Chart.bundle.js b/lib/chart.js/Chart.bundle.js new file mode 100644 index 00000000..d8ee292e --- /dev/null +++ b/lib/chart.js/Chart.bundle.js @@ -0,0 +1,20991 @@ +/*! + * Chart.js v2.9.3 + * https://www.chartjs.org + * (c) 2019 Chart.js Contributors + * Released under the MIT License + */ +;(function(global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' + ? (module.exports = factory()) + : typeof define === 'function' && define.amd + ? define(factory) + : ((global = global || self), (global.Chart = factory())) +})(this, function() { + 'use strict' + + var commonjsGlobal = + typeof globalThis !== 'undefined' + ? globalThis + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {} + + function commonjsRequire() { + throw new Error('Dynamic requires are not currently supported by rollup-plugin-commonjs') + } + + function createCommonjsModule(fn, module) { + return (module = { exports: {} }), fn(module, module.exports), module.exports + } + + function getCjsExportFromNamespace(n) { + return (n && n['default']) || n + } + + var colorName = { + aliceblue: [240, 248, 255], + antiquewhite: [250, 235, 215], + aqua: [0, 255, 255], + aquamarine: [127, 255, 212], + azure: [240, 255, 255], + beige: [245, 245, 220], + bisque: [255, 228, 196], + black: [0, 0, 0], + blanchedalmond: [255, 235, 205], + blue: [0, 0, 255], + blueviolet: [138, 43, 226], + brown: [165, 42, 42], + burlywood: [222, 184, 135], + cadetblue: [95, 158, 160], + chartreuse: [127, 255, 0], + chocolate: [210, 105, 30], + coral: [255, 127, 80], + cornflowerblue: [100, 149, 237], + cornsilk: [255, 248, 220], + crimson: [220, 20, 60], + cyan: [0, 255, 255], + darkblue: [0, 0, 139], + darkcyan: [0, 139, 139], + darkgoldenrod: [184, 134, 11], + darkgray: [169, 169, 169], + darkgreen: [0, 100, 0], + darkgrey: [169, 169, 169], + darkkhaki: [189, 183, 107], + darkmagenta: [139, 0, 139], + darkolivegreen: [85, 107, 47], + darkorange: [255, 140, 0], + darkorchid: [153, 50, 204], + darkred: [139, 0, 0], + darksalmon: [233, 150, 122], + darkseagreen: [143, 188, 143], + darkslateblue: [72, 61, 139], + darkslategray: [47, 79, 79], + darkslategrey: [47, 79, 79], + darkturquoise: [0, 206, 209], + darkviolet: [148, 0, 211], + deeppink: [255, 20, 147], + deepskyblue: [0, 191, 255], + dimgray: [105, 105, 105], + dimgrey: [105, 105, 105], + dodgerblue: [30, 144, 255], + firebrick: [178, 34, 34], + floralwhite: [255, 250, 240], + forestgreen: [34, 139, 34], + fuchsia: [255, 0, 255], + gainsboro: [220, 220, 220], + ghostwhite: [248, 248, 255], + gold: [255, 215, 0], + goldenrod: [218, 165, 32], + gray: [128, 128, 128], + green: [0, 128, 0], + greenyellow: [173, 255, 47], + grey: [128, 128, 128], + honeydew: [240, 255, 240], + hotpink: [255, 105, 180], + indianred: [205, 92, 92], + indigo: [75, 0, 130], + ivory: [255, 255, 240], + khaki: [240, 230, 140], + lavender: [230, 230, 250], + lavenderblush: [255, 240, 245], + lawngreen: [124, 252, 0], + lemonchiffon: [255, 250, 205], + lightblue: [173, 216, 230], + lightcoral: [240, 128, 128], + lightcyan: [224, 255, 255], + lightgoldenrodyellow: [250, 250, 210], + lightgray: [211, 211, 211], + lightgreen: [144, 238, 144], + lightgrey: [211, 211, 211], + lightpink: [255, 182, 193], + lightsalmon: [255, 160, 122], + lightseagreen: [32, 178, 170], + lightskyblue: [135, 206, 250], + lightslategray: [119, 136, 153], + lightslategrey: [119, 136, 153], + lightsteelblue: [176, 196, 222], + lightyellow: [255, 255, 224], + lime: [0, 255, 0], + limegreen: [50, 205, 50], + linen: [250, 240, 230], + magenta: [255, 0, 255], + maroon: [128, 0, 0], + mediumaquamarine: [102, 205, 170], + mediumblue: [0, 0, 205], + mediumorchid: [186, 85, 211], + mediumpurple: [147, 112, 219], + mediumseagreen: [60, 179, 113], + mediumslateblue: [123, 104, 238], + mediumspringgreen: [0, 250, 154], + mediumturquoise: [72, 209, 204], + mediumvioletred: [199, 21, 133], + midnightblue: [25, 25, 112], + mintcream: [245, 255, 250], + mistyrose: [255, 228, 225], + moccasin: [255, 228, 181], + navajowhite: [255, 222, 173], + navy: [0, 0, 128], + oldlace: [253, 245, 230], + olive: [128, 128, 0], + olivedrab: [107, 142, 35], + orange: [255, 165, 0], + orangered: [255, 69, 0], + orchid: [218, 112, 214], + palegoldenrod: [238, 232, 170], + palegreen: [152, 251, 152], + paleturquoise: [175, 238, 238], + palevioletred: [219, 112, 147], + papayawhip: [255, 239, 213], + peachpuff: [255, 218, 185], + peru: [205, 133, 63], + pink: [255, 192, 203], + plum: [221, 160, 221], + powderblue: [176, 224, 230], + purple: [128, 0, 128], + rebeccapurple: [102, 51, 153], + red: [255, 0, 0], + rosybrown: [188, 143, 143], + royalblue: [65, 105, 225], + saddlebrown: [139, 69, 19], + salmon: [250, 128, 114], + sandybrown: [244, 164, 96], + seagreen: [46, 139, 87], + seashell: [255, 245, 238], + sienna: [160, 82, 45], + silver: [192, 192, 192], + skyblue: [135, 206, 235], + slateblue: [106, 90, 205], + slategray: [112, 128, 144], + slategrey: [112, 128, 144], + snow: [255, 250, 250], + springgreen: [0, 255, 127], + steelblue: [70, 130, 180], + tan: [210, 180, 140], + teal: [0, 128, 128], + thistle: [216, 191, 216], + tomato: [255, 99, 71], + turquoise: [64, 224, 208], + violet: [238, 130, 238], + wheat: [245, 222, 179], + white: [255, 255, 255], + whitesmoke: [245, 245, 245], + yellow: [255, 255, 0], + yellowgreen: [154, 205, 50], + } + + var conversions = createCommonjsModule(function(module) { + /* MIT license */ + + // NOTE: conversions should only return primitive values (i.e. arrays, or + // values that give correct `typeof` results). + // do not use box values types (i.e. Number(), String(), etc.) + + var reverseKeywords = {} + for (var key in colorName) { + if (colorName.hasOwnProperty(key)) { + reverseKeywords[colorName[key]] = key + } + } + + var convert = (module.exports = { + rgb: { channels: 3, labels: 'rgb' }, + hsl: { channels: 3, labels: 'hsl' }, + hsv: { channels: 3, labels: 'hsv' }, + hwb: { channels: 3, labels: 'hwb' }, + cmyk: { channels: 4, labels: 'cmyk' }, + xyz: { channels: 3, labels: 'xyz' }, + lab: { channels: 3, labels: 'lab' }, + lch: { channels: 3, labels: 'lch' }, + hex: { channels: 1, labels: ['hex'] }, + keyword: { channels: 1, labels: ['keyword'] }, + ansi16: { channels: 1, labels: ['ansi16'] }, + ansi256: { channels: 1, labels: ['ansi256'] }, + hcg: { channels: 3, labels: ['h', 'c', 'g'] }, + apple: { channels: 3, labels: ['r16', 'g16', 'b16'] }, + gray: { channels: 1, labels: ['gray'] }, + }) + + // hide .channels and .labels properties + for (var model in convert) { + if (convert.hasOwnProperty(model)) { + if (!('channels' in convert[model])) { + throw new Error('missing channels property: ' + model) + } + + if (!('labels' in convert[model])) { + throw new Error('missing channel labels property: ' + model) + } + + if (convert[model].labels.length !== convert[model].channels) { + throw new Error('channel and label counts mismatch: ' + model) + } + + var channels = convert[model].channels + var labels = convert[model].labels + delete convert[model].channels + delete convert[model].labels + Object.defineProperty(convert[model], 'channels', { value: channels }) + Object.defineProperty(convert[model], 'labels', { value: labels }) + } + } + + convert.rgb.hsl = function(rgb) { + var r = rgb[0] / 255 + var g = rgb[1] / 255 + var b = rgb[2] / 255 + var min = Math.min(r, g, b) + var max = Math.max(r, g, b) + var delta = max - min + var h + var s + var l + + if (max === min) { + h = 0 + } else if (r === max) { + h = (g - b) / delta + } else if (g === max) { + h = 2 + (b - r) / delta + } else if (b === max) { + h = 4 + (r - g) / delta + } + + h = Math.min(h * 60, 360) + + if (h < 0) { + h += 360 + } + + l = (min + max) / 2 + + if (max === min) { + s = 0 + } else if (l <= 0.5) { + s = delta / (max + min) + } else { + s = delta / (2 - max - min) + } + + return [h, s * 100, l * 100] + } + + convert.rgb.hsv = function(rgb) { + var rdif + var gdif + var bdif + var h + var s + + var r = rgb[0] / 255 + var g = rgb[1] / 255 + var b = rgb[2] / 255 + var v = Math.max(r, g, b) + var diff = v - Math.min(r, g, b) + var diffc = function(c) { + return (v - c) / 6 / diff + 1 / 2 + } + + if (diff === 0) { + h = s = 0 + } else { + s = diff / v + rdif = diffc(r) + gdif = diffc(g) + bdif = diffc(b) + + if (r === v) { + h = bdif - gdif + } else if (g === v) { + h = 1 / 3 + rdif - bdif + } else if (b === v) { + h = 2 / 3 + gdif - rdif + } + if (h < 0) { + h += 1 + } else if (h > 1) { + h -= 1 + } + } + + return [h * 360, s * 100, v * 100] + } + + convert.rgb.hwb = function(rgb) { + var r = rgb[0] + var g = rgb[1] + var b = rgb[2] + var h = convert.rgb.hsl(rgb)[0] + var w = (1 / 255) * Math.min(r, Math.min(g, b)) + + b = 1 - (1 / 255) * Math.max(r, Math.max(g, b)) + + return [h, w * 100, b * 100] + } + + convert.rgb.cmyk = function(rgb) { + var r = rgb[0] / 255 + var g = rgb[1] / 255 + var b = rgb[2] / 255 + var c + var m + var y + var k + + k = Math.min(1 - r, 1 - g, 1 - b) + c = (1 - r - k) / (1 - k) || 0 + m = (1 - g - k) / (1 - k) || 0 + y = (1 - b - k) / (1 - k) || 0 + + return [c * 100, m * 100, y * 100, k * 100] + } + + /** + * See https://en.m.wikipedia.org/wiki/Euclidean_distance#Squared_Euclidean_distance + * */ + function comparativeDistance(x, y) { + return Math.pow(x[0] - y[0], 2) + Math.pow(x[1] - y[1], 2) + Math.pow(x[2] - y[2], 2) + } + + convert.rgb.keyword = function(rgb) { + var reversed = reverseKeywords[rgb] + if (reversed) { + return reversed + } + + var currentClosestDistance = Infinity + var currentClosestKeyword + + for (var keyword in colorName) { + if (colorName.hasOwnProperty(keyword)) { + var value = colorName[keyword] + + // Compute comparative distance + var distance = comparativeDistance(rgb, value) + + // Check if its less, if so set as closest + if (distance < currentClosestDistance) { + currentClosestDistance = distance + currentClosestKeyword = keyword + } + } + } + + return currentClosestKeyword + } + + convert.keyword.rgb = function(keyword) { + return colorName[keyword] + } + + convert.rgb.xyz = function(rgb) { + var r = rgb[0] / 255 + var g = rgb[1] / 255 + var b = rgb[2] / 255 + + // assume sRGB + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92 + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92 + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92 + + var x = r * 0.4124 + g * 0.3576 + b * 0.1805 + var y = r * 0.2126 + g * 0.7152 + b * 0.0722 + var z = r * 0.0193 + g * 0.1192 + b * 0.9505 + + return [x * 100, y * 100, z * 100] + } + + convert.rgb.lab = function(rgb) { + var xyz = convert.rgb.xyz(rgb) + var x = xyz[0] + var y = xyz[1] + var z = xyz[2] + var l + var a + var b + + x /= 95.047 + y /= 100 + z /= 108.883 + + x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116 + y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116 + z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116 + + l = 116 * y - 16 + a = 500 * (x - y) + b = 200 * (y - z) + + return [l, a, b] + } + + convert.hsl.rgb = function(hsl) { + var h = hsl[0] / 360 + var s = hsl[1] / 100 + var l = hsl[2] / 100 + var t1 + var t2 + var t3 + var rgb + var val + + if (s === 0) { + val = l * 255 + return [val, val, val] + } + + if (l < 0.5) { + t2 = l * (1 + s) + } else { + t2 = l + s - l * s + } + + t1 = 2 * l - t2 + + rgb = [0, 0, 0] + for (var i = 0; i < 3; i++) { + t3 = h + (1 / 3) * -(i - 1) + if (t3 < 0) { + t3++ + } + if (t3 > 1) { + t3-- + } + + if (6 * t3 < 1) { + val = t1 + (t2 - t1) * 6 * t3 + } else if (2 * t3 < 1) { + val = t2 + } else if (3 * t3 < 2) { + val = t1 + (t2 - t1) * (2 / 3 - t3) * 6 + } else { + val = t1 + } + + rgb[i] = val * 255 + } + + return rgb + } + + convert.hsl.hsv = function(hsl) { + var h = hsl[0] + var s = hsl[1] / 100 + var l = hsl[2] / 100 + var smin = s + var lmin = Math.max(l, 0.01) + var sv + var v + + l *= 2 + s *= l <= 1 ? l : 2 - l + smin *= lmin <= 1 ? lmin : 2 - lmin + v = (l + s) / 2 + sv = l === 0 ? (2 * smin) / (lmin + smin) : (2 * s) / (l + s) + + return [h, sv * 100, v * 100] + } + + convert.hsv.rgb = function(hsv) { + var h = hsv[0] / 60 + var s = hsv[1] / 100 + var v = hsv[2] / 100 + var hi = Math.floor(h) % 6 + + var f = h - Math.floor(h) + var p = 255 * v * (1 - s) + var q = 255 * v * (1 - s * f) + var t = 255 * v * (1 - s * (1 - f)) + v *= 255 + + switch (hi) { + case 0: + return [v, t, p] + case 1: + return [q, v, p] + case 2: + return [p, v, t] + case 3: + return [p, q, v] + case 4: + return [t, p, v] + case 5: + return [v, p, q] + } + } + + convert.hsv.hsl = function(hsv) { + var h = hsv[0] + var s = hsv[1] / 100 + var v = hsv[2] / 100 + var vmin = Math.max(v, 0.01) + var lmin + var sl + var l + + l = (2 - s) * v + lmin = (2 - s) * vmin + sl = s * vmin + sl /= lmin <= 1 ? lmin : 2 - lmin + sl = sl || 0 + l /= 2 + + return [h, sl * 100, l * 100] + } + + // http://dev.w3.org/csswg/css-color/#hwb-to-rgb + convert.hwb.rgb = function(hwb) { + var h = hwb[0] / 360 + var wh = hwb[1] / 100 + var bl = hwb[2] / 100 + var ratio = wh + bl + var i + var v + var f + var n + + // wh + bl cant be > 1 + if (ratio > 1) { + wh /= ratio + bl /= ratio + } + + i = Math.floor(6 * h) + v = 1 - bl + f = 6 * h - i + + if ((i & 0x01) !== 0) { + f = 1 - f + } + + n = wh + f * (v - wh) // linear interpolation + + var r + var g + var b + switch (i) { + default: + case 6: + case 0: + r = v + g = n + b = wh + break + case 1: + r = n + g = v + b = wh + break + case 2: + r = wh + g = v + b = n + break + case 3: + r = wh + g = n + b = v + break + case 4: + r = n + g = wh + b = v + break + case 5: + r = v + g = wh + b = n + break + } + + return [r * 255, g * 255, b * 255] + } + + convert.cmyk.rgb = function(cmyk) { + var c = cmyk[0] / 100 + var m = cmyk[1] / 100 + var y = cmyk[2] / 100 + var k = cmyk[3] / 100 + var r + var g + var b + + r = 1 - Math.min(1, c * (1 - k) + k) + g = 1 - Math.min(1, m * (1 - k) + k) + b = 1 - Math.min(1, y * (1 - k) + k) + + return [r * 255, g * 255, b * 255] + } + + convert.xyz.rgb = function(xyz) { + var x = xyz[0] / 100 + var y = xyz[1] / 100 + var z = xyz[2] / 100 + var r + var g + var b + + r = x * 3.2406 + y * -1.5372 + z * -0.4986 + g = x * -0.9689 + y * 1.8758 + z * 0.0415 + b = x * 0.0557 + y * -0.204 + z * 1.057 + + // assume sRGB + r = r > 0.0031308 ? 1.055 * Math.pow(r, 1.0 / 2.4) - 0.055 : r * 12.92 + + g = g > 0.0031308 ? 1.055 * Math.pow(g, 1.0 / 2.4) - 0.055 : g * 12.92 + + b = b > 0.0031308 ? 1.055 * Math.pow(b, 1.0 / 2.4) - 0.055 : b * 12.92 + + r = Math.min(Math.max(0, r), 1) + g = Math.min(Math.max(0, g), 1) + b = Math.min(Math.max(0, b), 1) + + return [r * 255, g * 255, b * 255] + } + + convert.xyz.lab = function(xyz) { + var x = xyz[0] + var y = xyz[1] + var z = xyz[2] + var l + var a + var b + + x /= 95.047 + y /= 100 + z /= 108.883 + + x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116 + y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116 + z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116 + + l = 116 * y - 16 + a = 500 * (x - y) + b = 200 * (y - z) + + return [l, a, b] + } + + convert.lab.xyz = function(lab) { + var l = lab[0] + var a = lab[1] + var b = lab[2] + var x + var y + var z + + y = (l + 16) / 116 + x = a / 500 + y + z = y - b / 200 + + var y2 = Math.pow(y, 3) + var x2 = Math.pow(x, 3) + var z2 = Math.pow(z, 3) + y = y2 > 0.008856 ? y2 : (y - 16 / 116) / 7.787 + x = x2 > 0.008856 ? x2 : (x - 16 / 116) / 7.787 + z = z2 > 0.008856 ? z2 : (z - 16 / 116) / 7.787 + + x *= 95.047 + y *= 100 + z *= 108.883 + + return [x, y, z] + } + + convert.lab.lch = function(lab) { + var l = lab[0] + var a = lab[1] + var b = lab[2] + var hr + var h + var c + + hr = Math.atan2(b, a) + h = (hr * 360) / 2 / Math.PI + + if (h < 0) { + h += 360 + } + + c = Math.sqrt(a * a + b * b) + + return [l, c, h] + } + + convert.lch.lab = function(lch) { + var l = lch[0] + var c = lch[1] + var h = lch[2] + var a + var b + var hr + + hr = (h / 360) * 2 * Math.PI + a = c * Math.cos(hr) + b = c * Math.sin(hr) + + return [l, a, b] + } + + convert.rgb.ansi16 = function(args) { + var r = args[0] + var g = args[1] + var b = args[2] + var value = 1 in arguments ? arguments[1] : convert.rgb.hsv(args)[2] // hsv -> ansi16 optimization + + value = Math.round(value / 50) + + if (value === 0) { + return 30 + } + + var ansi = 30 + ((Math.round(b / 255) << 2) | (Math.round(g / 255) << 1) | Math.round(r / 255)) + + if (value === 2) { + ansi += 60 + } + + return ansi + } + + convert.hsv.ansi16 = function(args) { + // optimization here; we already know the value and don't need to get + // it converted for us. + return convert.rgb.ansi16(convert.hsv.rgb(args), args[2]) + } + + convert.rgb.ansi256 = function(args) { + var r = args[0] + var g = args[1] + var b = args[2] + + // we use the extended greyscale palette here, with the exception of + // black and white. normal palette only has 4 greyscale shades. + if (r === g && g === b) { + if (r < 8) { + return 16 + } + + if (r > 248) { + return 231 + } + + return Math.round(((r - 8) / 247) * 24) + 232 + } + + var ansi = 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5) + + return ansi + } + + convert.ansi16.rgb = function(args) { + var color = args % 10 + + // handle greyscale + if (color === 0 || color === 7) { + if (args > 50) { + color += 3.5 + } + + color = (color / 10.5) * 255 + + return [color, color, color] + } + + var mult = (~~(args > 50) + 1) * 0.5 + var r = (color & 1) * mult * 255 + var g = ((color >> 1) & 1) * mult * 255 + var b = ((color >> 2) & 1) * mult * 255 + + return [r, g, b] + } + + convert.ansi256.rgb = function(args) { + // handle greyscale + if (args >= 232) { + var c = (args - 232) * 10 + 8 + return [c, c, c] + } + + args -= 16 + + var rem + var r = (Math.floor(args / 36) / 5) * 255 + var g = (Math.floor((rem = args % 36) / 6) / 5) * 255 + var b = ((rem % 6) / 5) * 255 + + return [r, g, b] + } + + convert.rgb.hex = function(args) { + var integer = + ((Math.round(args[0]) & 0xff) << 16) + ((Math.round(args[1]) & 0xff) << 8) + (Math.round(args[2]) & 0xff) + + var string = integer.toString(16).toUpperCase() + return '000000'.substring(string.length) + string + } + + convert.hex.rgb = function(args) { + var match = args.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i) + if (!match) { + return [0, 0, 0] + } + + var colorString = match[0] + + if (match[0].length === 3) { + colorString = colorString + .split('') + .map(function(char) { + return char + char + }) + .join('') + } + + var integer = parseInt(colorString, 16) + var r = (integer >> 16) & 0xff + var g = (integer >> 8) & 0xff + var b = integer & 0xff + + return [r, g, b] + } + + convert.rgb.hcg = function(rgb) { + var r = rgb[0] / 255 + var g = rgb[1] / 255 + var b = rgb[2] / 255 + var max = Math.max(Math.max(r, g), b) + var min = Math.min(Math.min(r, g), b) + var chroma = max - min + var grayscale + var hue + + if (chroma < 1) { + grayscale = min / (1 - chroma) + } else { + grayscale = 0 + } + + if (chroma <= 0) { + hue = 0 + } else if (max === r) { + hue = ((g - b) / chroma) % 6 + } else if (max === g) { + hue = 2 + (b - r) / chroma + } else { + hue = 4 + (r - g) / chroma + 4 + } + + hue /= 6 + hue %= 1 + + return [hue * 360, chroma * 100, grayscale * 100] + } + + convert.hsl.hcg = function(hsl) { + var s = hsl[1] / 100 + var l = hsl[2] / 100 + var c = 1 + var f = 0 + + if (l < 0.5) { + c = 2.0 * s * l + } else { + c = 2.0 * s * (1.0 - l) + } + + if (c < 1.0) { + f = (l - 0.5 * c) / (1.0 - c) + } + + return [hsl[0], c * 100, f * 100] + } + + convert.hsv.hcg = function(hsv) { + var s = hsv[1] / 100 + var v = hsv[2] / 100 + + var c = s * v + var f = 0 + + if (c < 1.0) { + f = (v - c) / (1 - c) + } + + return [hsv[0], c * 100, f * 100] + } + + convert.hcg.rgb = function(hcg) { + var h = hcg[0] / 360 + var c = hcg[1] / 100 + var g = hcg[2] / 100 + + if (c === 0.0) { + return [g * 255, g * 255, g * 255] + } + + var pure = [0, 0, 0] + var hi = (h % 1) * 6 + var v = hi % 1 + var w = 1 - v + var mg = 0 + + switch (Math.floor(hi)) { + case 0: + pure[0] = 1 + pure[1] = v + pure[2] = 0 + break + case 1: + pure[0] = w + pure[1] = 1 + pure[2] = 0 + break + case 2: + pure[0] = 0 + pure[1] = 1 + pure[2] = v + break + case 3: + pure[0] = 0 + pure[1] = w + pure[2] = 1 + break + case 4: + pure[0] = v + pure[1] = 0 + pure[2] = 1 + break + default: + pure[0] = 1 + pure[1] = 0 + pure[2] = w + } + + mg = (1.0 - c) * g + + return [(c * pure[0] + mg) * 255, (c * pure[1] + mg) * 255, (c * pure[2] + mg) * 255] + } + + convert.hcg.hsv = function(hcg) { + var c = hcg[1] / 100 + var g = hcg[2] / 100 + + var v = c + g * (1.0 - c) + var f = 0 + + if (v > 0.0) { + f = c / v + } + + return [hcg[0], f * 100, v * 100] + } + + convert.hcg.hsl = function(hcg) { + var c = hcg[1] / 100 + var g = hcg[2] / 100 + + var l = g * (1.0 - c) + 0.5 * c + var s = 0 + + if (l > 0.0 && l < 0.5) { + s = c / (2 * l) + } else if (l >= 0.5 && l < 1.0) { + s = c / (2 * (1 - l)) + } + + return [hcg[0], s * 100, l * 100] + } + + convert.hcg.hwb = function(hcg) { + var c = hcg[1] / 100 + var g = hcg[2] / 100 + var v = c + g * (1.0 - c) + return [hcg[0], (v - c) * 100, (1 - v) * 100] + } + + convert.hwb.hcg = function(hwb) { + var w = hwb[1] / 100 + var b = hwb[2] / 100 + var v = 1 - b + var c = v - w + var g = 0 + + if (c < 1) { + g = (v - c) / (1 - c) + } + + return [hwb[0], c * 100, g * 100] + } + + convert.apple.rgb = function(apple) { + return [(apple[0] / 65535) * 255, (apple[1] / 65535) * 255, (apple[2] / 65535) * 255] + } + + convert.rgb.apple = function(rgb) { + return [(rgb[0] / 255) * 65535, (rgb[1] / 255) * 65535, (rgb[2] / 255) * 65535] + } + + convert.gray.rgb = function(args) { + return [(args[0] / 100) * 255, (args[0] / 100) * 255, (args[0] / 100) * 255] + } + + convert.gray.hsl = convert.gray.hsv = function(args) { + return [0, 0, args[0]] + } + + convert.gray.hwb = function(gray) { + return [0, 100, gray[0]] + } + + convert.gray.cmyk = function(gray) { + return [0, 0, 0, gray[0]] + } + + convert.gray.lab = function(gray) { + return [gray[0], 0, 0] + } + + convert.gray.hex = function(gray) { + var val = Math.round((gray[0] / 100) * 255) & 0xff + var integer = (val << 16) + (val << 8) + val + + var string = integer.toString(16).toUpperCase() + return '000000'.substring(string.length) + string + } + + convert.rgb.gray = function(rgb) { + var val = (rgb[0] + rgb[1] + rgb[2]) / 3 + return [(val / 255) * 100] + } + }) + var conversions_1 = conversions.rgb + var conversions_2 = conversions.hsl + var conversions_3 = conversions.hsv + var conversions_4 = conversions.hwb + var conversions_5 = conversions.cmyk + var conversions_6 = conversions.xyz + var conversions_7 = conversions.lab + var conversions_8 = conversions.lch + var conversions_9 = conversions.hex + var conversions_10 = conversions.keyword + var conversions_11 = conversions.ansi16 + var conversions_12 = conversions.ansi256 + var conversions_13 = conversions.hcg + var conversions_14 = conversions.apple + var conversions_15 = conversions.gray + + /* + this function routes a model to all other models. + + all functions that are routed have a property `.conversion` attached + to the returned synthetic function. This property is an array + of strings, each with the steps in between the 'from' and 'to' + color models (inclusive). + + conversions that are not possible simply are not included. +*/ + + function buildGraph() { + var graph = {} + // https://jsperf.com/object-keys-vs-for-in-with-closure/3 + var models = Object.keys(conversions) + + for (var len = models.length, i = 0; i < len; i++) { + graph[models[i]] = { + // http://jsperf.com/1-vs-infinity + // micro-opt, but this is simple. + distance: -1, + parent: null, + } + } + + return graph + } + + // https://en.wikipedia.org/wiki/Breadth-first_search + function deriveBFS(fromModel) { + var graph = buildGraph() + var queue = [fromModel] // unshift -> queue -> pop + + graph[fromModel].distance = 0 + + while (queue.length) { + var current = queue.pop() + var adjacents = Object.keys(conversions[current]) + + for (var len = adjacents.length, i = 0; i < len; i++) { + var adjacent = adjacents[i] + var node = graph[adjacent] + + if (node.distance === -1) { + node.distance = graph[current].distance + 1 + node.parent = current + queue.unshift(adjacent) + } + } + } + + return graph + } + + function link(from, to) { + return function(args) { + return to(from(args)) + } + } + + function wrapConversion(toModel, graph) { + var path = [graph[toModel].parent, toModel] + var fn = conversions[graph[toModel].parent][toModel] + + var cur = graph[toModel].parent + while (graph[cur].parent) { + path.unshift(graph[cur].parent) + fn = link(conversions[graph[cur].parent][cur], fn) + cur = graph[cur].parent + } + + fn.conversion = path + return fn + } + + var route = function(fromModel) { + var graph = deriveBFS(fromModel) + var conversion = {} + + var models = Object.keys(graph) + for (var len = models.length, i = 0; i < len; i++) { + var toModel = models[i] + var node = graph[toModel] + + if (node.parent === null) { + // no possible conversion, or this node is the source model. + continue + } + + conversion[toModel] = wrapConversion(toModel, graph) + } + + return conversion + } + + var convert = {} + + var models = Object.keys(conversions) + + function wrapRaw(fn) { + var wrappedFn = function(args) { + if (args === undefined || args === null) { + return args + } + + if (arguments.length > 1) { + args = Array.prototype.slice.call(arguments) + } + + return fn(args) + } + + // preserve .conversion property if there is one + if ('conversion' in fn) { + wrappedFn.conversion = fn.conversion + } + + return wrappedFn + } + + function wrapRounded(fn) { + var wrappedFn = function(args) { + if (args === undefined || args === null) { + return args + } + + if (arguments.length > 1) { + args = Array.prototype.slice.call(arguments) + } + + var result = fn(args) + + // we're assuming the result is an array here. + // see notice in conversions.js; don't use box types + // in conversion functions. + if (typeof result === 'object') { + for (var len = result.length, i = 0; i < len; i++) { + result[i] = Math.round(result[i]) + } + } + + return result + } + + // preserve .conversion property if there is one + if ('conversion' in fn) { + wrappedFn.conversion = fn.conversion + } + + return wrappedFn + } + + models.forEach(function(fromModel) { + convert[fromModel] = {} + + Object.defineProperty(convert[fromModel], 'channels', { value: conversions[fromModel].channels }) + Object.defineProperty(convert[fromModel], 'labels', { value: conversions[fromModel].labels }) + + var routes = route(fromModel) + var routeModels = Object.keys(routes) + + routeModels.forEach(function(toModel) { + var fn = routes[toModel] + + convert[fromModel][toModel] = wrapRounded(fn) + convert[fromModel][toModel].raw = wrapRaw(fn) + }) + }) + + var colorConvert = convert + + var colorName$1 = { + aliceblue: [240, 248, 255], + antiquewhite: [250, 235, 215], + aqua: [0, 255, 255], + aquamarine: [127, 255, 212], + azure: [240, 255, 255], + beige: [245, 245, 220], + bisque: [255, 228, 196], + black: [0, 0, 0], + blanchedalmond: [255, 235, 205], + blue: [0, 0, 255], + blueviolet: [138, 43, 226], + brown: [165, 42, 42], + burlywood: [222, 184, 135], + cadetblue: [95, 158, 160], + chartreuse: [127, 255, 0], + chocolate: [210, 105, 30], + coral: [255, 127, 80], + cornflowerblue: [100, 149, 237], + cornsilk: [255, 248, 220], + crimson: [220, 20, 60], + cyan: [0, 255, 255], + darkblue: [0, 0, 139], + darkcyan: [0, 139, 139], + darkgoldenrod: [184, 134, 11], + darkgray: [169, 169, 169], + darkgreen: [0, 100, 0], + darkgrey: [169, 169, 169], + darkkhaki: [189, 183, 107], + darkmagenta: [139, 0, 139], + darkolivegreen: [85, 107, 47], + darkorange: [255, 140, 0], + darkorchid: [153, 50, 204], + darkred: [139, 0, 0], + darksalmon: [233, 150, 122], + darkseagreen: [143, 188, 143], + darkslateblue: [72, 61, 139], + darkslategray: [47, 79, 79], + darkslategrey: [47, 79, 79], + darkturquoise: [0, 206, 209], + darkviolet: [148, 0, 211], + deeppink: [255, 20, 147], + deepskyblue: [0, 191, 255], + dimgray: [105, 105, 105], + dimgrey: [105, 105, 105], + dodgerblue: [30, 144, 255], + firebrick: [178, 34, 34], + floralwhite: [255, 250, 240], + forestgreen: [34, 139, 34], + fuchsia: [255, 0, 255], + gainsboro: [220, 220, 220], + ghostwhite: [248, 248, 255], + gold: [255, 215, 0], + goldenrod: [218, 165, 32], + gray: [128, 128, 128], + green: [0, 128, 0], + greenyellow: [173, 255, 47], + grey: [128, 128, 128], + honeydew: [240, 255, 240], + hotpink: [255, 105, 180], + indianred: [205, 92, 92], + indigo: [75, 0, 130], + ivory: [255, 255, 240], + khaki: [240, 230, 140], + lavender: [230, 230, 250], + lavenderblush: [255, 240, 245], + lawngreen: [124, 252, 0], + lemonchiffon: [255, 250, 205], + lightblue: [173, 216, 230], + lightcoral: [240, 128, 128], + lightcyan: [224, 255, 255], + lightgoldenrodyellow: [250, 250, 210], + lightgray: [211, 211, 211], + lightgreen: [144, 238, 144], + lightgrey: [211, 211, 211], + lightpink: [255, 182, 193], + lightsalmon: [255, 160, 122], + lightseagreen: [32, 178, 170], + lightskyblue: [135, 206, 250], + lightslategray: [119, 136, 153], + lightslategrey: [119, 136, 153], + lightsteelblue: [176, 196, 222], + lightyellow: [255, 255, 224], + lime: [0, 255, 0], + limegreen: [50, 205, 50], + linen: [250, 240, 230], + magenta: [255, 0, 255], + maroon: [128, 0, 0], + mediumaquamarine: [102, 205, 170], + mediumblue: [0, 0, 205], + mediumorchid: [186, 85, 211], + mediumpurple: [147, 112, 219], + mediumseagreen: [60, 179, 113], + mediumslateblue: [123, 104, 238], + mediumspringgreen: [0, 250, 154], + mediumturquoise: [72, 209, 204], + mediumvioletred: [199, 21, 133], + midnightblue: [25, 25, 112], + mintcream: [245, 255, 250], + mistyrose: [255, 228, 225], + moccasin: [255, 228, 181], + navajowhite: [255, 222, 173], + navy: [0, 0, 128], + oldlace: [253, 245, 230], + olive: [128, 128, 0], + olivedrab: [107, 142, 35], + orange: [255, 165, 0], + orangered: [255, 69, 0], + orchid: [218, 112, 214], + palegoldenrod: [238, 232, 170], + palegreen: [152, 251, 152], + paleturquoise: [175, 238, 238], + palevioletred: [219, 112, 147], + papayawhip: [255, 239, 213], + peachpuff: [255, 218, 185], + peru: [205, 133, 63], + pink: [255, 192, 203], + plum: [221, 160, 221], + powderblue: [176, 224, 230], + purple: [128, 0, 128], + rebeccapurple: [102, 51, 153], + red: [255, 0, 0], + rosybrown: [188, 143, 143], + royalblue: [65, 105, 225], + saddlebrown: [139, 69, 19], + salmon: [250, 128, 114], + sandybrown: [244, 164, 96], + seagreen: [46, 139, 87], + seashell: [255, 245, 238], + sienna: [160, 82, 45], + silver: [192, 192, 192], + skyblue: [135, 206, 235], + slateblue: [106, 90, 205], + slategray: [112, 128, 144], + slategrey: [112, 128, 144], + snow: [255, 250, 250], + springgreen: [0, 255, 127], + steelblue: [70, 130, 180], + tan: [210, 180, 140], + teal: [0, 128, 128], + thistle: [216, 191, 216], + tomato: [255, 99, 71], + turquoise: [64, 224, 208], + violet: [238, 130, 238], + wheat: [245, 222, 179], + white: [255, 255, 255], + whitesmoke: [245, 245, 245], + yellow: [255, 255, 0], + yellowgreen: [154, 205, 50], + } + + /* MIT license */ + + var colorString = { + getRgba: getRgba, + getHsla: getHsla, + getRgb: getRgb, + getHsl: getHsl, + getHwb: getHwb, + getAlpha: getAlpha, + + hexString: hexString, + rgbString: rgbString, + rgbaString: rgbaString, + percentString: percentString, + percentaString: percentaString, + hslString: hslString, + hslaString: hslaString, + hwbString: hwbString, + keyword: keyword, + } + + function getRgba(string) { + if (!string) { + return + } + var abbr = /^#([a-fA-F0-9]{3,4})$/i, + hex = /^#([a-fA-F0-9]{6}([a-fA-F0-9]{2})?)$/i, + rgba = /^rgba?\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/i, + per = /^rgba?\(\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/i, + keyword = /(\w+)/ + + var rgb = [0, 0, 0], + a = 1, + match = string.match(abbr), + hexAlpha = '' + if (match) { + match = match[1] + hexAlpha = match[3] + for (var i = 0; i < rgb.length; i++) { + rgb[i] = parseInt(match[i] + match[i], 16) + } + if (hexAlpha) { + a = Math.round((parseInt(hexAlpha + hexAlpha, 16) / 255) * 100) / 100 + } + } else if ((match = string.match(hex))) { + hexAlpha = match[2] + match = match[1] + for (var i = 0; i < rgb.length; i++) { + rgb[i] = parseInt(match.slice(i * 2, i * 2 + 2), 16) + } + if (hexAlpha) { + a = Math.round((parseInt(hexAlpha, 16) / 255) * 100) / 100 + } + } else if ((match = string.match(rgba))) { + for (var i = 0; i < rgb.length; i++) { + rgb[i] = parseInt(match[i + 1]) + } + a = parseFloat(match[4]) + } else if ((match = string.match(per))) { + for (var i = 0; i < rgb.length; i++) { + rgb[i] = Math.round(parseFloat(match[i + 1]) * 2.55) + } + a = parseFloat(match[4]) + } else if ((match = string.match(keyword))) { + if (match[1] == 'transparent') { + return [0, 0, 0, 0] + } + rgb = colorName$1[match[1]] + if (!rgb) { + return + } + } + + for (var i = 0; i < rgb.length; i++) { + rgb[i] = scale(rgb[i], 0, 255) + } + if (!a && a != 0) { + a = 1 + } else { + a = scale(a, 0, 1) + } + rgb[3] = a + return rgb + } + + function getHsla(string) { + if (!string) { + return + } + var hsl = /^hsla?\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/ + var match = string.match(hsl) + if (match) { + var alpha = parseFloat(match[4]) + var h = scale(parseInt(match[1]), 0, 360), + s = scale(parseFloat(match[2]), 0, 100), + l = scale(parseFloat(match[3]), 0, 100), + a = scale(isNaN(alpha) ? 1 : alpha, 0, 1) + return [h, s, l, a] + } + } + + function getHwb(string) { + if (!string) { + return + } + var hwb = /^hwb\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/ + var match = string.match(hwb) + if (match) { + var alpha = parseFloat(match[4]) + var h = scale(parseInt(match[1]), 0, 360), + w = scale(parseFloat(match[2]), 0, 100), + b = scale(parseFloat(match[3]), 0, 100), + a = scale(isNaN(alpha) ? 1 : alpha, 0, 1) + return [h, w, b, a] + } + } + + function getRgb(string) { + var rgba = getRgba(string) + return rgba && rgba.slice(0, 3) + } + + function getHsl(string) { + var hsla = getHsla(string) + return hsla && hsla.slice(0, 3) + } + + function getAlpha(string) { + var vals = getRgba(string) + if (vals) { + return vals[3] + } else if ((vals = getHsla(string))) { + return vals[3] + } else if ((vals = getHwb(string))) { + return vals[3] + } + } + + // generators + function hexString(rgba, a) { + var a = a !== undefined && rgba.length === 3 ? a : rgba[3] + return ( + '#' + + hexDouble(rgba[0]) + + hexDouble(rgba[1]) + + hexDouble(rgba[2]) + + (a >= 0 && a < 1 ? hexDouble(Math.round(a * 255)) : '') + ) + } + + function rgbString(rgba, alpha) { + if (alpha < 1 || (rgba[3] && rgba[3] < 1)) { + return rgbaString(rgba, alpha) + } + return 'rgb(' + rgba[0] + ', ' + rgba[1] + ', ' + rgba[2] + ')' + } + + function rgbaString(rgba, alpha) { + if (alpha === undefined) { + alpha = rgba[3] !== undefined ? rgba[3] : 1 + } + return 'rgba(' + rgba[0] + ', ' + rgba[1] + ', ' + rgba[2] + ', ' + alpha + ')' + } + + function percentString(rgba, alpha) { + if (alpha < 1 || (rgba[3] && rgba[3] < 1)) { + return percentaString(rgba, alpha) + } + var r = Math.round((rgba[0] / 255) * 100), + g = Math.round((rgba[1] / 255) * 100), + b = Math.round((rgba[2] / 255) * 100) + + return 'rgb(' + r + '%, ' + g + '%, ' + b + '%)' + } + + function percentaString(rgba, alpha) { + var r = Math.round((rgba[0] / 255) * 100), + g = Math.round((rgba[1] / 255) * 100), + b = Math.round((rgba[2] / 255) * 100) + return 'rgba(' + r + '%, ' + g + '%, ' + b + '%, ' + (alpha || rgba[3] || 1) + ')' + } + + function hslString(hsla, alpha) { + if (alpha < 1 || (hsla[3] && hsla[3] < 1)) { + return hslaString(hsla, alpha) + } + return 'hsl(' + hsla[0] + ', ' + hsla[1] + '%, ' + hsla[2] + '%)' + } + + function hslaString(hsla, alpha) { + if (alpha === undefined) { + alpha = hsla[3] !== undefined ? hsla[3] : 1 + } + return 'hsla(' + hsla[0] + ', ' + hsla[1] + '%, ' + hsla[2] + '%, ' + alpha + ')' + } + + // hwb is a bit different than rgb(a) & hsl(a) since there is no alpha specific syntax + // (hwb have alpha optional & 1 is default value) + function hwbString(hwb, alpha) { + if (alpha === undefined) { + alpha = hwb[3] !== undefined ? hwb[3] : 1 + } + return ( + 'hwb(' + + hwb[0] + + ', ' + + hwb[1] + + '%, ' + + hwb[2] + + '%' + + (alpha !== undefined && alpha !== 1 ? ', ' + alpha : '') + + ')' + ) + } + + function keyword(rgb) { + return reverseNames[rgb.slice(0, 3)] + } + + // helpers + function scale(num, min, max) { + return Math.min(Math.max(min, num), max) + } + + function hexDouble(num) { + var str = num.toString(16).toUpperCase() + return str.length < 2 ? '0' + str : str + } + + //create a list of reverse color names + var reverseNames = {} + for (var name in colorName$1) { + reverseNames[colorName$1[name]] = name + } + + /* MIT license */ + + var Color = function(obj) { + if (obj instanceof Color) { + return obj + } + if (!(this instanceof Color)) { + return new Color(obj) + } + + this.valid = false + this.values = { + rgb: [0, 0, 0], + hsl: [0, 0, 0], + hsv: [0, 0, 0], + hwb: [0, 0, 0], + cmyk: [0, 0, 0, 0], + alpha: 1, + } + + // parse Color() argument + var vals + if (typeof obj === 'string') { + vals = colorString.getRgba(obj) + if (vals) { + this.setValues('rgb', vals) + } else if ((vals = colorString.getHsla(obj))) { + this.setValues('hsl', vals) + } else if ((vals = colorString.getHwb(obj))) { + this.setValues('hwb', vals) + } + } else if (typeof obj === 'object') { + vals = obj + if (vals.r !== undefined || vals.red !== undefined) { + this.setValues('rgb', vals) + } else if (vals.l !== undefined || vals.lightness !== undefined) { + this.setValues('hsl', vals) + } else if (vals.v !== undefined || vals.value !== undefined) { + this.setValues('hsv', vals) + } else if (vals.w !== undefined || vals.whiteness !== undefined) { + this.setValues('hwb', vals) + } else if (vals.c !== undefined || vals.cyan !== undefined) { + this.setValues('cmyk', vals) + } + } + } + + Color.prototype = { + isValid: function() { + return this.valid + }, + rgb: function() { + return this.setSpace('rgb', arguments) + }, + hsl: function() { + return this.setSpace('hsl', arguments) + }, + hsv: function() { + return this.setSpace('hsv', arguments) + }, + hwb: function() { + return this.setSpace('hwb', arguments) + }, + cmyk: function() { + return this.setSpace('cmyk', arguments) + }, + + rgbArray: function() { + return this.values.rgb + }, + hslArray: function() { + return this.values.hsl + }, + hsvArray: function() { + return this.values.hsv + }, + hwbArray: function() { + var values = this.values + if (values.alpha !== 1) { + return values.hwb.concat([values.alpha]) + } + return values.hwb + }, + cmykArray: function() { + return this.values.cmyk + }, + rgbaArray: function() { + var values = this.values + return values.rgb.concat([values.alpha]) + }, + hslaArray: function() { + var values = this.values + return values.hsl.concat([values.alpha]) + }, + alpha: function(val) { + if (val === undefined) { + return this.values.alpha + } + this.setValues('alpha', val) + return this + }, + + red: function(val) { + return this.setChannel('rgb', 0, val) + }, + green: function(val) { + return this.setChannel('rgb', 1, val) + }, + blue: function(val) { + return this.setChannel('rgb', 2, val) + }, + hue: function(val) { + if (val) { + val %= 360 + val = val < 0 ? 360 + val : val + } + return this.setChannel('hsl', 0, val) + }, + saturation: function(val) { + return this.setChannel('hsl', 1, val) + }, + lightness: function(val) { + return this.setChannel('hsl', 2, val) + }, + saturationv: function(val) { + return this.setChannel('hsv', 1, val) + }, + whiteness: function(val) { + return this.setChannel('hwb', 1, val) + }, + blackness: function(val) { + return this.setChannel('hwb', 2, val) + }, + value: function(val) { + return this.setChannel('hsv', 2, val) + }, + cyan: function(val) { + return this.setChannel('cmyk', 0, val) + }, + magenta: function(val) { + return this.setChannel('cmyk', 1, val) + }, + yellow: function(val) { + return this.setChannel('cmyk', 2, val) + }, + black: function(val) { + return this.setChannel('cmyk', 3, val) + }, + + hexString: function() { + return colorString.hexString(this.values.rgb) + }, + rgbString: function() { + return colorString.rgbString(this.values.rgb, this.values.alpha) + }, + rgbaString: function() { + return colorString.rgbaString(this.values.rgb, this.values.alpha) + }, + percentString: function() { + return colorString.percentString(this.values.rgb, this.values.alpha) + }, + hslString: function() { + return colorString.hslString(this.values.hsl, this.values.alpha) + }, + hslaString: function() { + return colorString.hslaString(this.values.hsl, this.values.alpha) + }, + hwbString: function() { + return colorString.hwbString(this.values.hwb, this.values.alpha) + }, + keyword: function() { + return colorString.keyword(this.values.rgb, this.values.alpha) + }, + + rgbNumber: function() { + var rgb = this.values.rgb + return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2] + }, + + luminosity: function() { + // http://www.w3.org/TR/WCAG20/#relativeluminancedef + var rgb = this.values.rgb + var lum = [] + for (var i = 0; i < rgb.length; i++) { + var chan = rgb[i] / 255 + lum[i] = chan <= 0.03928 ? chan / 12.92 : Math.pow((chan + 0.055) / 1.055, 2.4) + } + return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2] + }, + + contrast: function(color2) { + // http://www.w3.org/TR/WCAG20/#contrast-ratiodef + var lum1 = this.luminosity() + var lum2 = color2.luminosity() + if (lum1 > lum2) { + return (lum1 + 0.05) / (lum2 + 0.05) + } + return (lum2 + 0.05) / (lum1 + 0.05) + }, + + level: function(color2) { + var contrastRatio = this.contrast(color2) + if (contrastRatio >= 7.1) { + return 'AAA' + } + + return contrastRatio >= 4.5 ? 'AA' : '' + }, + + dark: function() { + // YIQ equation from http://24ways.org/2010/calculating-color-contrast + var rgb = this.values.rgb + var yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 + return yiq < 128 + }, + + light: function() { + return !this.dark() + }, + + negate: function() { + var rgb = [] + for (var i = 0; i < 3; i++) { + rgb[i] = 255 - this.values.rgb[i] + } + this.setValues('rgb', rgb) + return this + }, + + lighten: function(ratio) { + var hsl = this.values.hsl + hsl[2] += hsl[2] * ratio + this.setValues('hsl', hsl) + return this + }, + + darken: function(ratio) { + var hsl = this.values.hsl + hsl[2] -= hsl[2] * ratio + this.setValues('hsl', hsl) + return this + }, + + saturate: function(ratio) { + var hsl = this.values.hsl + hsl[1] += hsl[1] * ratio + this.setValues('hsl', hsl) + return this + }, + + desaturate: function(ratio) { + var hsl = this.values.hsl + hsl[1] -= hsl[1] * ratio + this.setValues('hsl', hsl) + return this + }, + + whiten: function(ratio) { + var hwb = this.values.hwb + hwb[1] += hwb[1] * ratio + this.setValues('hwb', hwb) + return this + }, + + blacken: function(ratio) { + var hwb = this.values.hwb + hwb[2] += hwb[2] * ratio + this.setValues('hwb', hwb) + return this + }, + + greyscale: function() { + var rgb = this.values.rgb + // http://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale + var val = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11 + this.setValues('rgb', [val, val, val]) + return this + }, + + clearer: function(ratio) { + var alpha = this.values.alpha + this.setValues('alpha', alpha - alpha * ratio) + return this + }, + + opaquer: function(ratio) { + var alpha = this.values.alpha + this.setValues('alpha', alpha + alpha * ratio) + return this + }, + + rotate: function(degrees) { + var hsl = this.values.hsl + var hue = (hsl[0] + degrees) % 360 + hsl[0] = hue < 0 ? 360 + hue : hue + this.setValues('hsl', hsl) + return this + }, + + /** + * Ported from sass implementation in C + * https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209 + */ + mix: function(mixinColor, weight) { + var color1 = this + var color2 = mixinColor + var p = weight === undefined ? 0.5 : weight + + var w = 2 * p - 1 + var a = color1.alpha() - color2.alpha() + + var w1 = ((w * a === -1 ? w : (w + a) / (1 + w * a)) + 1) / 2.0 + var w2 = 1 - w1 + + return this.rgb( + w1 * color1.red() + w2 * color2.red(), + w1 * color1.green() + w2 * color2.green(), + w1 * color1.blue() + w2 * color2.blue(), + ).alpha(color1.alpha() * p + color2.alpha() * (1 - p)) + }, + + toJSON: function() { + return this.rgb() + }, + + clone: function() { + // NOTE(SB): using node-clone creates a dependency to Buffer when using browserify, + // making the final build way to big to embed in Chart.js. So let's do it manually, + // assuming that values to clone are 1 dimension arrays containing only numbers, + // except 'alpha' which is a number. + var result = new Color() + var source = this.values + var target = result.values + var value, type + + for (var prop in source) { + if (source.hasOwnProperty(prop)) { + value = source[prop] + type = {}.toString.call(value) + if (type === '[object Array]') { + target[prop] = value.slice(0) + } else if (type === '[object Number]') { + target[prop] = value + } else { + console.error('unexpected color value:', value) + } + } + } + + return result + }, + } + + Color.prototype.spaces = { + rgb: ['red', 'green', 'blue'], + hsl: ['hue', 'saturation', 'lightness'], + hsv: ['hue', 'saturation', 'value'], + hwb: ['hue', 'whiteness', 'blackness'], + cmyk: ['cyan', 'magenta', 'yellow', 'black'], + } + + Color.prototype.maxes = { + rgb: [255, 255, 255], + hsl: [360, 100, 100], + hsv: [360, 100, 100], + hwb: [360, 100, 100], + cmyk: [100, 100, 100, 100], + } + + Color.prototype.getValues = function(space) { + var values = this.values + var vals = {} + + for (var i = 0; i < space.length; i++) { + vals[space.charAt(i)] = values[space][i] + } + + if (values.alpha !== 1) { + vals.a = values.alpha + } + + // {r: 255, g: 255, b: 255, a: 0.4} + return vals + } + + Color.prototype.setValues = function(space, vals) { + var values = this.values + var spaces = this.spaces + var maxes = this.maxes + var alpha = 1 + var i + + this.valid = true + + if (space === 'alpha') { + alpha = vals + } else if (vals.length) { + // [10, 10, 10] + values[space] = vals.slice(0, space.length) + alpha = vals[space.length] + } else if (vals[space.charAt(0)] !== undefined) { + // {r: 10, g: 10, b: 10} + for (i = 0; i < space.length; i++) { + values[space][i] = vals[space.charAt(i)] + } + + alpha = vals.a + } else if (vals[spaces[space][0]] !== undefined) { + // {red: 10, green: 10, blue: 10} + var chans = spaces[space] + + for (i = 0; i < space.length; i++) { + values[space][i] = vals[chans[i]] + } + + alpha = vals.alpha + } + + values.alpha = Math.max(0, Math.min(1, alpha === undefined ? values.alpha : alpha)) + + if (space === 'alpha') { + return false + } + + var capped + + // cap values of the space prior converting all values + for (i = 0; i < space.length; i++) { + capped = Math.max(0, Math.min(maxes[space][i], values[space][i])) + values[space][i] = Math.round(capped) + } + + // convert to all the other color spaces + for (var sname in spaces) { + if (sname !== space) { + values[sname] = colorConvert[space][sname](values[space]) + } + } + + return true + } + + Color.prototype.setSpace = function(space, args) { + var vals = args[0] + + if (vals === undefined) { + // color.rgb() + return this.getValues(space) + } + + // color.rgb(10, 10, 10) + if (typeof vals === 'number') { + vals = Array.prototype.slice.call(args) + } + + this.setValues(space, vals) + return this + } + + Color.prototype.setChannel = function(space, index, val) { + var svalues = this.values[space] + if (val === undefined) { + // color.red() + return svalues[index] + } else if (val === svalues[index]) { + // color.red(color.red()) + return this + } + + // color.red(100) + svalues[index] = val + this.setValues(space, svalues) + + return this + } + + if (typeof window !== 'undefined') { + window.Color = Color + } + + var chartjsColor = Color + + /** + * @namespace Chart.helpers + */ + var helpers = { + /** + * An empty function that can be used, for example, for optional callback. + */ + noop: function() {}, + + /** + * Returns a unique id, sequentially generated from a global variable. + * @returns {number} + * @function + */ + uid: (function() { + var id = 0 + return function() { + return id++ + } + })(), + + /** + * Returns true if `value` is neither null nor undefined, else returns false. + * @param {*} value - The value to test. + * @returns {boolean} + * @since 2.7.0 + */ + isNullOrUndef: function(value) { + return value === null || typeof value === 'undefined' + }, + + /** + * Returns true if `value` is an array (including typed arrays), else returns false. + * @param {*} value - The value to test. + * @returns {boolean} + * @function + */ + isArray: function(value) { + if (Array.isArray && Array.isArray(value)) { + return true + } + var type = Object.prototype.toString.call(value) + if (type.substr(0, 7) === '[object' && type.substr(-6) === 'Array]') { + return true + } + return false + }, + + /** + * Returns true if `value` is an object (excluding null), else returns false. + * @param {*} value - The value to test. + * @returns {boolean} + * @since 2.7.0 + */ + isObject: function(value) { + return value !== null && Object.prototype.toString.call(value) === '[object Object]' + }, + + /** + * Returns true if `value` is a finite number, else returns false + * @param {*} value - The value to test. + * @returns {boolean} + */ + isFinite: function(value) { + return (typeof value === 'number' || value instanceof Number) && isFinite(value) + }, + + /** + * Returns `value` if defined, else returns `defaultValue`. + * @param {*} value - The value to return if defined. + * @param {*} defaultValue - The value to return if `value` is undefined. + * @returns {*} + */ + valueOrDefault: function(value, defaultValue) { + return typeof value === 'undefined' ? defaultValue : value + }, + + /** + * Returns value at the given `index` in array if defined, else returns `defaultValue`. + * @param {Array} value - The array to lookup for value at `index`. + * @param {number} index - The index in `value` to lookup for value. + * @param {*} defaultValue - The value to return if `value[index]` is undefined. + * @returns {*} + */ + valueAtIndexOrDefault: function(value, index, defaultValue) { + return helpers.valueOrDefault(helpers.isArray(value) ? value[index] : value, defaultValue) + }, + + /** + * Calls `fn` with the given `args` in the scope defined by `thisArg` and returns the + * value returned by `fn`. If `fn` is not a function, this method returns undefined. + * @param {function} fn - The function to call. + * @param {Array|undefined|null} args - The arguments with which `fn` should be called. + * @param {object} [thisArg] - The value of `this` provided for the call to `fn`. + * @returns {*} + */ + callback: function(fn, args, thisArg) { + if (fn && typeof fn.call === 'function') { + return fn.apply(thisArg, args) + } + }, + + /** + * Note(SB) for performance sake, this method should only be used when loopable type + * is unknown or in none intensive code (not called often and small loopable). Else + * it's preferable to use a regular for() loop and save extra function calls. + * @param {object|Array} loopable - The object or array to be iterated. + * @param {function} fn - The function to call for each item. + * @param {object} [thisArg] - The value of `this` provided for the call to `fn`. + * @param {boolean} [reverse] - If true, iterates backward on the loopable. + */ + each: function(loopable, fn, thisArg, reverse) { + var i, len, keys + if (helpers.isArray(loopable)) { + len = loopable.length + if (reverse) { + for (i = len - 1; i >= 0; i--) { + fn.call(thisArg, loopable[i], i) + } + } else { + for (i = 0; i < len; i++) { + fn.call(thisArg, loopable[i], i) + } + } + } else if (helpers.isObject(loopable)) { + keys = Object.keys(loopable) + len = keys.length + for (i = 0; i < len; i++) { + fn.call(thisArg, loopable[keys[i]], keys[i]) + } + } + }, + + /** + * Returns true if the `a0` and `a1` arrays have the same content, else returns false. + * @see https://stackoverflow.com/a/14853974 + * @param {Array} a0 - The array to compare + * @param {Array} a1 - The array to compare + * @returns {boolean} + */ + arrayEquals: function(a0, a1) { + var i, ilen, v0, v1 + + if (!a0 || !a1 || a0.length !== a1.length) { + return false + } + + for (i = 0, ilen = a0.length; i < ilen; ++i) { + v0 = a0[i] + v1 = a1[i] + + if (v0 instanceof Array && v1 instanceof Array) { + if (!helpers.arrayEquals(v0, v1)) { + return false + } + } else if (v0 !== v1) { + // NOTE: two different object instances will never be equal: {x:20} != {x:20} + return false + } + } + + return true + }, + + /** + * Returns a deep copy of `source` without keeping references on objects and arrays. + * @param {*} source - The value to clone. + * @returns {*} + */ + clone: function(source) { + if (helpers.isArray(source)) { + return source.map(helpers.clone) + } + + if (helpers.isObject(source)) { + var target = {} + var keys = Object.keys(source) + var klen = keys.length + var k = 0 + + for (; k < klen; ++k) { + target[keys[k]] = helpers.clone(source[keys[k]]) + } + + return target + } + + return source + }, + + /** + * The default merger when Chart.helpers.merge is called without merger option. + * Note(SB): also used by mergeConfig and mergeScaleConfig as fallback. + * @private + */ + _merger: function(key, target, source, options) { + var tval = target[key] + var sval = source[key] + + if (helpers.isObject(tval) && helpers.isObject(sval)) { + helpers.merge(tval, sval, options) + } else { + target[key] = helpers.clone(sval) + } + }, + + /** + * Merges source[key] in target[key] only if target[key] is undefined. + * @private + */ + _mergerIf: function(key, target, source) { + var tval = target[key] + var sval = source[key] + + if (helpers.isObject(tval) && helpers.isObject(sval)) { + helpers.mergeIf(tval, sval) + } else if (!target.hasOwnProperty(key)) { + target[key] = helpers.clone(sval) + } + }, + + /** + * Recursively deep copies `source` properties into `target` with the given `options`. + * IMPORTANT: `target` is not cloned and will be updated with `source` properties. + * @param {object} target - The target object in which all sources are merged into. + * @param {object|object[]} source - Object(s) to merge into `target`. + * @param {object} [options] - Merging options: + * @param {function} [options.merger] - The merge method (key, target, source, options) + * @returns {object} The `target` object. + */ + merge: function(target, source, options) { + var sources = helpers.isArray(source) ? source : [source] + var ilen = sources.length + var merge, i, keys, klen, k + + if (!helpers.isObject(target)) { + return target + } + + options = options || {} + merge = options.merger || helpers._merger + + for (i = 0; i < ilen; ++i) { + source = sources[i] + if (!helpers.isObject(source)) { + continue + } + + keys = Object.keys(source) + for (k = 0, klen = keys.length; k < klen; ++k) { + merge(keys[k], target, source, options) + } + } + + return target + }, + + /** + * Recursively deep copies `source` properties into `target` *only* if not defined in target. + * IMPORTANT: `target` is not cloned and will be updated with `source` properties. + * @param {object} target - The target object in which all sources are merged into. + * @param {object|object[]} source - Object(s) to merge into `target`. + * @returns {object} The `target` object. + */ + mergeIf: function(target, source) { + return helpers.merge(target, source, { merger: helpers._mergerIf }) + }, + + /** + * Applies the contents of two or more objects together into the first object. + * @param {object} target - The target object in which all objects are merged into. + * @param {object} arg1 - Object containing additional properties to merge in target. + * @param {object} argN - Additional objects containing properties to merge in target. + * @returns {object} The `target` object. + */ + extend: + Object.assign || + function(target) { + return helpers.merge(target, [].slice.call(arguments, 1), { + merger: function(key, dst, src) { + dst[key] = src[key] + }, + }) + }, + + /** + * Basic javascript inheritance based on the model created in Backbone.js + */ + inherits: function(extensions) { + var me = this + var ChartElement = + extensions && extensions.hasOwnProperty('constructor') + ? extensions.constructor + : function() { + return me.apply(this, arguments) + } + + var Surrogate = function() { + this.constructor = ChartElement + } + + Surrogate.prototype = me.prototype + ChartElement.prototype = new Surrogate() + ChartElement.extend = helpers.inherits + + if (extensions) { + helpers.extend(ChartElement.prototype, extensions) + } + + ChartElement.__super__ = me.prototype + return ChartElement + }, + + _deprecated: function(scope, value, previous, current) { + if (value !== undefined) { + console.warn(scope + ': "' + previous + '" is deprecated. Please use "' + current + '" instead') + } + }, + } + + var helpers_core = helpers + + // DEPRECATIONS + + /** + * Provided for backward compatibility, use Chart.helpers.callback instead. + * @function Chart.helpers.callCallback + * @deprecated since version 2.6.0 + * @todo remove at version 3 + * @private + */ + helpers.callCallback = helpers.callback + + /** + * Provided for backward compatibility, use Array.prototype.indexOf instead. + * Array.prototype.indexOf compatibility: Chrome, Opera, Safari, FF1.5+, IE9+ + * @function Chart.helpers.indexOf + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers.indexOf = function(array, item, fromIndex) { + return Array.prototype.indexOf.call(array, item, fromIndex) + } + + /** + * Provided for backward compatibility, use Chart.helpers.valueOrDefault instead. + * @function Chart.helpers.getValueOrDefault + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers.getValueOrDefault = helpers.valueOrDefault + + /** + * Provided for backward compatibility, use Chart.helpers.valueAtIndexOrDefault instead. + * @function Chart.helpers.getValueAtIndexOrDefault + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers.getValueAtIndexOrDefault = helpers.valueAtIndexOrDefault + + /** + * Easing functions adapted from Robert Penner's easing equations. + * @namespace Chart.helpers.easingEffects + * @see http://www.robertpenner.com/easing/ + */ + var effects = { + linear: function(t) { + return t + }, + + easeInQuad: function(t) { + return t * t + }, + + easeOutQuad: function(t) { + return -t * (t - 2) + }, + + easeInOutQuad: function(t) { + if ((t /= 0.5) < 1) { + return 0.5 * t * t + } + return -0.5 * (--t * (t - 2) - 1) + }, + + easeInCubic: function(t) { + return t * t * t + }, + + easeOutCubic: function(t) { + return (t = t - 1) * t * t + 1 + }, + + easeInOutCubic: function(t) { + if ((t /= 0.5) < 1) { + return 0.5 * t * t * t + } + return 0.5 * ((t -= 2) * t * t + 2) + }, + + easeInQuart: function(t) { + return t * t * t * t + }, + + easeOutQuart: function(t) { + return -((t = t - 1) * t * t * t - 1) + }, + + easeInOutQuart: function(t) { + if ((t /= 0.5) < 1) { + return 0.5 * t * t * t * t + } + return -0.5 * ((t -= 2) * t * t * t - 2) + }, + + easeInQuint: function(t) { + return t * t * t * t * t + }, + + easeOutQuint: function(t) { + return (t = t - 1) * t * t * t * t + 1 + }, + + easeInOutQuint: function(t) { + if ((t /= 0.5) < 1) { + return 0.5 * t * t * t * t * t + } + return 0.5 * ((t -= 2) * t * t * t * t + 2) + }, + + easeInSine: function(t) { + return -Math.cos(t * (Math.PI / 2)) + 1 + }, + + easeOutSine: function(t) { + return Math.sin(t * (Math.PI / 2)) + }, + + easeInOutSine: function(t) { + return -0.5 * (Math.cos(Math.PI * t) - 1) + }, + + easeInExpo: function(t) { + return t === 0 ? 0 : Math.pow(2, 10 * (t - 1)) + }, + + easeOutExpo: function(t) { + return t === 1 ? 1 : -Math.pow(2, -10 * t) + 1 + }, + + easeInOutExpo: function(t) { + if (t === 0) { + return 0 + } + if (t === 1) { + return 1 + } + if ((t /= 0.5) < 1) { + return 0.5 * Math.pow(2, 10 * (t - 1)) + } + return 0.5 * (-Math.pow(2, -10 * --t) + 2) + }, + + easeInCirc: function(t) { + if (t >= 1) { + return t + } + return -(Math.sqrt(1 - t * t) - 1) + }, + + easeOutCirc: function(t) { + return Math.sqrt(1 - (t = t - 1) * t) + }, + + easeInOutCirc: function(t) { + if ((t /= 0.5) < 1) { + return -0.5 * (Math.sqrt(1 - t * t) - 1) + } + return 0.5 * (Math.sqrt(1 - (t -= 2) * t) + 1) + }, + + easeInElastic: function(t) { + var s = 1.70158 + var p = 0 + var a = 1 + if (t === 0) { + return 0 + } + if (t === 1) { + return 1 + } + if (!p) { + p = 0.3 + } + if (a < 1) { + a = 1 + s = p / 4 + } else { + s = (p / (2 * Math.PI)) * Math.asin(1 / a) + } + return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin(((t - s) * (2 * Math.PI)) / p)) + }, + + easeOutElastic: function(t) { + var s = 1.70158 + var p = 0 + var a = 1 + if (t === 0) { + return 0 + } + if (t === 1) { + return 1 + } + if (!p) { + p = 0.3 + } + if (a < 1) { + a = 1 + s = p / 4 + } else { + s = (p / (2 * Math.PI)) * Math.asin(1 / a) + } + return a * Math.pow(2, -10 * t) * Math.sin(((t - s) * (2 * Math.PI)) / p) + 1 + }, + + easeInOutElastic: function(t) { + var s = 1.70158 + var p = 0 + var a = 1 + if (t === 0) { + return 0 + } + if ((t /= 0.5) === 2) { + return 1 + } + if (!p) { + p = 0.45 + } + if (a < 1) { + a = 1 + s = p / 4 + } else { + s = (p / (2 * Math.PI)) * Math.asin(1 / a) + } + if (t < 1) { + return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin(((t - s) * (2 * Math.PI)) / p)) + } + return a * Math.pow(2, -10 * (t -= 1)) * Math.sin(((t - s) * (2 * Math.PI)) / p) * 0.5 + 1 + }, + easeInBack: function(t) { + var s = 1.70158 + return t * t * ((s + 1) * t - s) + }, + + easeOutBack: function(t) { + var s = 1.70158 + return (t = t - 1) * t * ((s + 1) * t + s) + 1 + }, + + easeInOutBack: function(t) { + var s = 1.70158 + if ((t /= 0.5) < 1) { + return 0.5 * (t * t * (((s *= 1.525) + 1) * t - s)) + } + return 0.5 * ((t -= 2) * t * (((s *= 1.525) + 1) * t + s) + 2) + }, + + easeInBounce: function(t) { + return 1 - effects.easeOutBounce(1 - t) + }, + + easeOutBounce: function(t) { + if (t < 1 / 2.75) { + return 7.5625 * t * t + } + if (t < 2 / 2.75) { + return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75 + } + if (t < 2.5 / 2.75) { + return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375 + } + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375 + }, + + easeInOutBounce: function(t) { + if (t < 0.5) { + return effects.easeInBounce(t * 2) * 0.5 + } + return effects.easeOutBounce(t * 2 - 1) * 0.5 + 0.5 + }, + } + + var helpers_easing = { + effects: effects, + } + + // DEPRECATIONS + + /** + * Provided for backward compatibility, use Chart.helpers.easing.effects instead. + * @function Chart.helpers.easingEffects + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers_core.easingEffects = effects + + var PI = Math.PI + var RAD_PER_DEG = PI / 180 + var DOUBLE_PI = PI * 2 + var HALF_PI = PI / 2 + var QUARTER_PI = PI / 4 + var TWO_THIRDS_PI = (PI * 2) / 3 + + /** + * @namespace Chart.helpers.canvas + */ + var exports$1 = { + /** + * Clears the entire canvas associated to the given `chart`. + * @param {Chart} chart - The chart for which to clear the canvas. + */ + clear: function(chart) { + chart.ctx.clearRect(0, 0, chart.width, chart.height) + }, + + /** + * Creates a "path" for a rectangle with rounded corners at position (x, y) with a + * given size (width, height) and the same `radius` for all corners. + * @param {CanvasRenderingContext2D} ctx - The canvas 2D Context. + * @param {number} x - The x axis of the coordinate for the rectangle starting point. + * @param {number} y - The y axis of the coordinate for the rectangle starting point. + * @param {number} width - The rectangle's width. + * @param {number} height - The rectangle's height. + * @param {number} radius - The rounded amount (in pixels) for the four corners. + * @todo handle `radius` as top-left, top-right, bottom-right, bottom-left array/object? + */ + roundedRect: function(ctx, x, y, width, height, radius) { + if (radius) { + var r = Math.min(radius, height / 2, width / 2) + var left = x + r + var top = y + r + var right = x + width - r + var bottom = y + height - r + + ctx.moveTo(x, top) + if (left < right && top < bottom) { + ctx.arc(left, top, r, -PI, -HALF_PI) + ctx.arc(right, top, r, -HALF_PI, 0) + ctx.arc(right, bottom, r, 0, HALF_PI) + ctx.arc(left, bottom, r, HALF_PI, PI) + } else if (left < right) { + ctx.moveTo(left, y) + ctx.arc(right, top, r, -HALF_PI, HALF_PI) + ctx.arc(left, top, r, HALF_PI, PI + HALF_PI) + } else if (top < bottom) { + ctx.arc(left, top, r, -PI, 0) + ctx.arc(left, bottom, r, 0, PI) + } else { + ctx.arc(left, top, r, -PI, PI) + } + ctx.closePath() + ctx.moveTo(x, y) + } else { + ctx.rect(x, y, width, height) + } + }, + + drawPoint: function(ctx, style, radius, x, y, rotation) { + var type, xOffset, yOffset, size, cornerRadius + var rad = (rotation || 0) * RAD_PER_DEG + + if (style && typeof style === 'object') { + type = style.toString() + if (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]') { + ctx.save() + ctx.translate(x, y) + ctx.rotate(rad) + ctx.drawImage(style, -style.width / 2, -style.height / 2, style.width, style.height) + ctx.restore() + return + } + } + + if (isNaN(radius) || radius <= 0) { + return + } + + ctx.beginPath() + + switch (style) { + // Default includes circle + default: + ctx.arc(x, y, radius, 0, DOUBLE_PI) + ctx.closePath() + break + case 'triangle': + ctx.moveTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius) + rad += TWO_THIRDS_PI + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius) + rad += TWO_THIRDS_PI + ctx.lineTo(x + Math.sin(rad) * radius, y - Math.cos(rad) * radius) + ctx.closePath() + break + case 'rectRounded': + // NOTE: the rounded rect implementation changed to use `arc` instead of + // `quadraticCurveTo` since it generates better results when rect is + // almost a circle. 0.516 (instead of 0.5) produces results with visually + // closer proportion to the previous impl and it is inscribed in the + // circle with `radius`. For more details, see the following PRs: + // https://github.com/chartjs/Chart.js/issues/5597 + // https://github.com/chartjs/Chart.js/issues/5858 + cornerRadius = radius * 0.516 + size = radius - cornerRadius + xOffset = Math.cos(rad + QUARTER_PI) * size + yOffset = Math.sin(rad + QUARTER_PI) * size + ctx.arc(x - xOffset, y - yOffset, cornerRadius, rad - PI, rad - HALF_PI) + ctx.arc(x + yOffset, y - xOffset, cornerRadius, rad - HALF_PI, rad) + ctx.arc(x + xOffset, y + yOffset, cornerRadius, rad, rad + HALF_PI) + ctx.arc(x - yOffset, y + xOffset, cornerRadius, rad + HALF_PI, rad + PI) + ctx.closePath() + break + case 'rect': + if (!rotation) { + size = Math.SQRT1_2 * radius + ctx.rect(x - size, y - size, 2 * size, 2 * size) + break + } + rad += QUARTER_PI + /* falls through */ + case 'rectRot': + xOffset = Math.cos(rad) * radius + yOffset = Math.sin(rad) * radius + ctx.moveTo(x - xOffset, y - yOffset) + ctx.lineTo(x + yOffset, y - xOffset) + ctx.lineTo(x + xOffset, y + yOffset) + ctx.lineTo(x - yOffset, y + xOffset) + ctx.closePath() + break + case 'crossRot': + rad += QUARTER_PI + /* falls through */ + case 'cross': + xOffset = Math.cos(rad) * radius + yOffset = Math.sin(rad) * radius + ctx.moveTo(x - xOffset, y - yOffset) + ctx.lineTo(x + xOffset, y + yOffset) + ctx.moveTo(x + yOffset, y - xOffset) + ctx.lineTo(x - yOffset, y + xOffset) + break + case 'star': + xOffset = Math.cos(rad) * radius + yOffset = Math.sin(rad) * radius + ctx.moveTo(x - xOffset, y - yOffset) + ctx.lineTo(x + xOffset, y + yOffset) + ctx.moveTo(x + yOffset, y - xOffset) + ctx.lineTo(x - yOffset, y + xOffset) + rad += QUARTER_PI + xOffset = Math.cos(rad) * radius + yOffset = Math.sin(rad) * radius + ctx.moveTo(x - xOffset, y - yOffset) + ctx.lineTo(x + xOffset, y + yOffset) + ctx.moveTo(x + yOffset, y - xOffset) + ctx.lineTo(x - yOffset, y + xOffset) + break + case 'line': + xOffset = Math.cos(rad) * radius + yOffset = Math.sin(rad) * radius + ctx.moveTo(x - xOffset, y - yOffset) + ctx.lineTo(x + xOffset, y + yOffset) + break + case 'dash': + ctx.moveTo(x, y) + ctx.lineTo(x + Math.cos(rad) * radius, y + Math.sin(rad) * radius) + break + } + + ctx.fill() + ctx.stroke() + }, + + /** + * Returns true if the point is inside the rectangle + * @param {object} point - The point to test + * @param {object} area - The rectangle + * @returns {boolean} + * @private + */ + _isPointInArea: function(point, area) { + var epsilon = 1e-6 // 1e-6 is margin in pixels for accumulated error. + + return ( + point.x > area.left - epsilon && + point.x < area.right + epsilon && + point.y > area.top - epsilon && + point.y < area.bottom + epsilon + ) + }, + + clipArea: function(ctx, area) { + ctx.save() + ctx.beginPath() + ctx.rect(area.left, area.top, area.right - area.left, area.bottom - area.top) + ctx.clip() + }, + + unclipArea: function(ctx) { + ctx.restore() + }, + + lineTo: function(ctx, previous, target, flip) { + var stepped = target.steppedLine + if (stepped) { + if (stepped === 'middle') { + var midpoint = (previous.x + target.x) / 2.0 + ctx.lineTo(midpoint, flip ? target.y : previous.y) + ctx.lineTo(midpoint, flip ? previous.y : target.y) + } else if ((stepped === 'after' && !flip) || (stepped !== 'after' && flip)) { + ctx.lineTo(previous.x, target.y) + } else { + ctx.lineTo(target.x, previous.y) + } + ctx.lineTo(target.x, target.y) + return + } + + if (!target.tension) { + ctx.lineTo(target.x, target.y) + return + } + + ctx.bezierCurveTo( + flip ? previous.controlPointPreviousX : previous.controlPointNextX, + flip ? previous.controlPointPreviousY : previous.controlPointNextY, + flip ? target.controlPointNextX : target.controlPointPreviousX, + flip ? target.controlPointNextY : target.controlPointPreviousY, + target.x, + target.y, + ) + }, + } + + var helpers_canvas = exports$1 + + // DEPRECATIONS + + /** + * Provided for backward compatibility, use Chart.helpers.canvas.clear instead. + * @namespace Chart.helpers.clear + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers_core.clear = exports$1.clear + + /** + * Provided for backward compatibility, use Chart.helpers.canvas.roundedRect instead. + * @namespace Chart.helpers.drawRoundedRectangle + * @deprecated since version 2.7.0 + * @todo remove at version 3 + * @private + */ + helpers_core.drawRoundedRectangle = function(ctx) { + ctx.beginPath() + exports$1.roundedRect.apply(exports$1, arguments) + } + + var defaults = { + /** + * @private + */ + _set: function(scope, values) { + return helpers_core.merge(this[scope] || (this[scope] = {}), values) + }, + } + + // TODO(v3): remove 'global' from namespace. all default are global and + // there's inconsistency around which options are under 'global' + defaults._set('global', { + defaultColor: 'rgba(0,0,0,0.1)', + defaultFontColor: '#666', + defaultFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", + defaultFontSize: 12, + defaultFontStyle: 'normal', + defaultLineHeight: 1.2, + showLines: true, + }) + + var core_defaults = defaults + + var valueOrDefault = helpers_core.valueOrDefault + + /** + * Converts the given font object into a CSS font string. + * @param {object} font - A font object. + * @return {string} The CSS font string. See https://developer.mozilla.org/en-US/docs/Web/CSS/font + * @private + */ + function toFontString(font) { + if (!font || helpers_core.isNullOrUndef(font.size) || helpers_core.isNullOrUndef(font.family)) { + return null + } + + return ( + (font.style ? font.style + ' ' : '') + (font.weight ? font.weight + ' ' : '') + font.size + 'px ' + font.family + ) + } + + /** + * @alias Chart.helpers.options + * @namespace + */ + var helpers_options = { + /** + * Converts the given line height `value` in pixels for a specific font `size`. + * @param {number|string} value - The lineHeight to parse (eg. 1.6, '14px', '75%', '1.6em'). + * @param {number} size - The font size (in pixels) used to resolve relative `value`. + * @returns {number} The effective line height in pixels (size * 1.2 if value is invalid). + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/line-height + * @since 2.7.0 + */ + toLineHeight: function(value, size) { + var matches = ('' + value).match(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/) + if (!matches || matches[1] === 'normal') { + return size * 1.2 + } + + value = +matches[2] + + switch (matches[3]) { + case 'px': + return value + case '%': + value /= 100 + break + } + + return size * value + }, + + /** + * Converts the given value into a padding object with pre-computed width/height. + * @param {number|object} value - If a number, set the value to all TRBL component, + * else, if and object, use defined properties and sets undefined ones to 0. + * @returns {object} The padding values (top, right, bottom, left, width, height) + * @since 2.7.0 + */ + toPadding: function(value) { + var t, r, b, l + + if (helpers_core.isObject(value)) { + t = +value.top || 0 + r = +value.right || 0 + b = +value.bottom || 0 + l = +value.left || 0 + } else { + t = r = b = l = +value || 0 + } + + return { + top: t, + right: r, + bottom: b, + left: l, + height: t + b, + width: l + r, + } + }, + + /** + * Parses font options and returns the font object. + * @param {object} options - A object that contains font options to be parsed. + * @return {object} The font object. + * @todo Support font.* options and renamed to toFont(). + * @private + */ + _parseFont: function(options) { + var globalDefaults = core_defaults.global + var size = valueOrDefault(options.fontSize, globalDefaults.defaultFontSize) + var font = { + family: valueOrDefault(options.fontFamily, globalDefaults.defaultFontFamily), + lineHeight: helpers_core.options.toLineHeight( + valueOrDefault(options.lineHeight, globalDefaults.defaultLineHeight), + size, + ), + size: size, + style: valueOrDefault(options.fontStyle, globalDefaults.defaultFontStyle), + weight: null, + string: '', + } + + font.string = toFontString(font) + return font + }, + + /** + * Evaluates the given `inputs` sequentially and returns the first defined value. + * @param {Array} inputs - An array of values, falling back to the last value. + * @param {object} [context] - If defined and the current value is a function, the value + * is called with `context` as first argument and the result becomes the new input. + * @param {number} [index] - If defined and the current value is an array, the value + * at `index` become the new input. + * @param {object} [info] - object to return information about resolution in + * @param {boolean} [info.cacheable] - Will be set to `false` if option is not cacheable. + * @since 2.7.0 + */ + resolve: function(inputs, context, index, info) { + var cacheable = true + var i, ilen, value + + for (i = 0, ilen = inputs.length; i < ilen; ++i) { + value = inputs[i] + if (value === undefined) { + continue + } + if (context !== undefined && typeof value === 'function') { + value = value(context) + cacheable = false + } + if (index !== undefined && helpers_core.isArray(value)) { + value = value[index] + cacheable = false + } + if (value !== undefined) { + if (info && !cacheable) { + info.cacheable = false + } + return value + } + } + }, + } + + /** + * @alias Chart.helpers.math + * @namespace + */ + var exports$2 = { + /** + * Returns an array of factors sorted from 1 to sqrt(value) + * @private + */ + _factorize: function(value) { + var result = [] + var sqrt = Math.sqrt(value) + var i + + for (i = 1; i < sqrt; i++) { + if (value % i === 0) { + result.push(i) + result.push(value / i) + } + } + if (sqrt === (sqrt | 0)) { + // if value is a square number + result.push(sqrt) + } + + result + .sort(function(a, b) { + return a - b + }) + .pop() + return result + }, + + log10: + Math.log10 || + function(x) { + var exponent = Math.log(x) * Math.LOG10E // Math.LOG10E = 1 / Math.LN10. + // Check for whole powers of 10, + // which due to floating point rounding error should be corrected. + var powerOf10 = Math.round(exponent) + var isPowerOf10 = x === Math.pow(10, powerOf10) + + return isPowerOf10 ? powerOf10 : exponent + }, + } + + var helpers_math = exports$2 + + // DEPRECATIONS + + /** + * Provided for backward compatibility, use Chart.helpers.math.log10 instead. + * @namespace Chart.helpers.log10 + * @deprecated since version 2.9.0 + * @todo remove at version 3 + * @private + */ + helpers_core.log10 = exports$2.log10 + + var getRtlAdapter = function(rectX, width) { + return { + x: function(x) { + return rectX + rectX + width - x + }, + setWidth: function(w) { + width = w + }, + textAlign: function(align) { + if (align === 'center') { + return align + } + return align === 'right' ? 'left' : 'right' + }, + xPlus: function(x, value) { + return x - value + }, + leftForLtr: function(x, itemWidth) { + return x - itemWidth + }, + } + } + + var getLtrAdapter = function() { + return { + x: function(x) { + return x + }, + setWidth: function(w) { + // eslint-disable-line no-unused-vars + }, + textAlign: function(align) { + return align + }, + xPlus: function(x, value) { + return x + value + }, + leftForLtr: function(x, _itemWidth) { + // eslint-disable-line no-unused-vars + return x + }, + } + } + + var getAdapter = function(rtl, rectX, width) { + return rtl ? getRtlAdapter(rectX, width) : getLtrAdapter() + } + + var overrideTextDirection = function(ctx, direction) { + var style, original + if (direction === 'ltr' || direction === 'rtl') { + style = ctx.canvas.style + original = [style.getPropertyValue('direction'), style.getPropertyPriority('direction')] + + style.setProperty('direction', direction, 'important') + ctx.prevTextDirection = original + } + } + + var restoreTextDirection = function(ctx) { + var original = ctx.prevTextDirection + if (original !== undefined) { + delete ctx.prevTextDirection + ctx.canvas.style.setProperty('direction', original[0], original[1]) + } + } + + var helpers_rtl = { + getRtlAdapter: getAdapter, + overrideTextDirection: overrideTextDirection, + restoreTextDirection: restoreTextDirection, + } + + var helpers$1 = helpers_core + var easing = helpers_easing + var canvas = helpers_canvas + var options = helpers_options + var math = helpers_math + var rtl = helpers_rtl + helpers$1.easing = easing + helpers$1.canvas = canvas + helpers$1.options = options + helpers$1.math = math + helpers$1.rtl = rtl + + function interpolate(start, view, model, ease) { + var keys = Object.keys(model) + var i, ilen, key, actual, origin, target, type, c0, c1 + + for (i = 0, ilen = keys.length; i < ilen; ++i) { + key = keys[i] + + target = model[key] + + // if a value is added to the model after pivot() has been called, the view + // doesn't contain it, so let's initialize the view to the target value. + if (!view.hasOwnProperty(key)) { + view[key] = target + } + + actual = view[key] + + if (actual === target || key[0] === '_') { + continue + } + + if (!start.hasOwnProperty(key)) { + start[key] = actual + } + + origin = start[key] + + type = typeof target + + if (type === typeof origin) { + if (type === 'string') { + c0 = chartjsColor(origin) + if (c0.valid) { + c1 = chartjsColor(target) + if (c1.valid) { + view[key] = c1.mix(c0, ease).rgbString() + continue + } + } + } else if (helpers$1.isFinite(origin) && helpers$1.isFinite(target)) { + view[key] = origin + (target - origin) * ease + continue + } + } + + view[key] = target + } + } + + var Element = function(configuration) { + helpers$1.extend(this, configuration) + this.initialize.apply(this, arguments) + } + + helpers$1.extend(Element.prototype, { + _type: undefined, + + initialize: function() { + this.hidden = false + }, + + pivot: function() { + var me = this + if (!me._view) { + me._view = helpers$1.extend({}, me._model) + } + me._start = {} + return me + }, + + transition: function(ease) { + var me = this + var model = me._model + var start = me._start + var view = me._view + + // No animation -> No Transition + if (!model || ease === 1) { + me._view = helpers$1.extend({}, model) + me._start = null + return me + } + + if (!view) { + view = me._view = {} + } + + if (!start) { + start = me._start = {} + } + + interpolate(start, view, model, ease) + + return me + }, + + tooltipPosition: function() { + return { + x: this._model.x, + y: this._model.y, + } + }, + + hasValue: function() { + return helpers$1.isNumber(this._model.x) && helpers$1.isNumber(this._model.y) + }, + }) + + Element.extend = helpers$1.inherits + + var core_element = Element + + var exports$3 = core_element.extend({ + chart: null, // the animation associated chart instance + currentStep: 0, // the current animation step + numSteps: 60, // default number of steps + easing: '', // the easing to use for this animation + render: null, // render function used by the animation service + + onAnimationProgress: null, // user specified callback to fire on each step of the animation + onAnimationComplete: null, // user specified callback to fire when the animation finishes + }) + + var core_animation = exports$3 + + // DEPRECATIONS + + /** + * Provided for backward compatibility, use Chart.Animation instead + * @prop Chart.Animation#animationObject + * @deprecated since version 2.6.0 + * @todo remove at version 3 + */ + Object.defineProperty(exports$3.prototype, 'animationObject', { + get: function() { + return this + }, + }) + + /** + * Provided for backward compatibility, use Chart.Animation#chart instead + * @prop Chart.Animation#chartInstance + * @deprecated since version 2.6.0 + * @todo remove at version 3 + */ + Object.defineProperty(exports$3.prototype, 'chartInstance', { + get: function() { + return this.chart + }, + set: function(value) { + this.chart = value + }, + }) + + core_defaults._set('global', { + animation: { + duration: 1000, + easing: 'easeOutQuart', + onProgress: helpers$1.noop, + onComplete: helpers$1.noop, + }, + }) + + var core_animations = { + animations: [], + request: null, + + /** + * @param {Chart} chart - The chart to animate. + * @param {Chart.Animation} animation - The animation that we will animate. + * @param {number} duration - The animation duration in ms. + * @param {boolean} lazy - if true, the chart is not marked as animating to enable more responsive interactions + */ + addAnimation: function(chart, animation, duration, lazy) { + var animations = this.animations + var i, ilen + + animation.chart = chart + animation.startTime = Date.now() + animation.duration = duration + + if (!lazy) { + chart.animating = true + } + + for (i = 0, ilen = animations.length; i < ilen; ++i) { + if (animations[i].chart === chart) { + animations[i] = animation + return + } + } + + animations.push(animation) + + // If there are no animations queued, manually kickstart a digest, for lack of a better word + if (animations.length === 1) { + this.requestAnimationFrame() + } + }, + + cancelAnimation: function(chart) { + var index = helpers$1.findIndex(this.animations, function(animation) { + return animation.chart === chart + }) + + if (index !== -1) { + this.animations.splice(index, 1) + chart.animating = false + } + }, + + requestAnimationFrame: function() { + var me = this + if (me.request === null) { + // Skip animation frame requests until the active one is executed. + // This can happen when processing mouse events, e.g. 'mousemove' + // and 'mouseout' events will trigger multiple renders. + me.request = helpers$1.requestAnimFrame.call(window, function() { + me.request = null + me.startDigest() + }) + } + }, + + /** + * @private + */ + startDigest: function() { + var me = this + + me.advance() + + // Do we have more stuff to animate? + if (me.animations.length > 0) { + me.requestAnimationFrame() + } + }, + + /** + * @private + */ + advance: function() { + var animations = this.animations + var animation, chart, numSteps, nextStep + var i = 0 + + // 1 animation per chart, so we are looping charts here + while (i < animations.length) { + animation = animations[i] + chart = animation.chart + numSteps = animation.numSteps + + // Make sure that currentStep starts at 1 + // https://github.com/chartjs/Chart.js/issues/6104 + nextStep = Math.floor(((Date.now() - animation.startTime) / animation.duration) * numSteps) + 1 + animation.currentStep = Math.min(nextStep, numSteps) + + helpers$1.callback(animation.render, [chart, animation], chart) + helpers$1.callback(animation.onAnimationProgress, [animation], chart) + + if (animation.currentStep >= numSteps) { + helpers$1.callback(animation.onAnimationComplete, [animation], chart) + chart.animating = false + animations.splice(i, 1) + } else { + ++i + } + } + }, + } + + var resolve = helpers$1.options.resolve + + var arrayEvents = ['push', 'pop', 'shift', 'splice', 'unshift'] + + /** + * Hooks the array methods that add or remove values ('push', pop', 'shift', 'splice', + * 'unshift') and notify the listener AFTER the array has been altered. Listeners are + * called on the 'onData*' callbacks (e.g. onDataPush, etc.) with same arguments. + */ + function listenArrayEvents(array, listener) { + if (array._chartjs) { + array._chartjs.listeners.push(listener) + return + } + + Object.defineProperty(array, '_chartjs', { + configurable: true, + enumerable: false, + value: { + listeners: [listener], + }, + }) + + arrayEvents.forEach(function(key) { + var method = 'onData' + key.charAt(0).toUpperCase() + key.slice(1) + var base = array[key] + + Object.defineProperty(array, key, { + configurable: true, + enumerable: false, + value: function() { + var args = Array.prototype.slice.call(arguments) + var res = base.apply(this, args) + + helpers$1.each(array._chartjs.listeners, function(object) { + if (typeof object[method] === 'function') { + object[method].apply(object, args) + } + }) + + return res + }, + }) + }) + } + + /** + * Removes the given array event listener and cleanup extra attached properties (such as + * the _chartjs stub and overridden methods) if array doesn't have any more listeners. + */ + function unlistenArrayEvents(array, listener) { + var stub = array._chartjs + if (!stub) { + return + } + + var listeners = stub.listeners + var index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + + if (listeners.length > 0) { + return + } + + arrayEvents.forEach(function(key) { + delete array[key] + }) + + delete array._chartjs + } + + // Base class for all dataset controllers (line, bar, etc) + var DatasetController = function(chart, datasetIndex) { + this.initialize(chart, datasetIndex) + } + + helpers$1.extend(DatasetController.prototype, { + /** + * Element type used to generate a meta dataset (e.g. Chart.element.Line). + * @type {Chart.core.element} + */ + datasetElementType: null, + + /** + * Element type used to generate a meta data (e.g. Chart.element.Point). + * @type {Chart.core.element} + */ + dataElementType: null, + + /** + * Dataset element option keys to be resolved in _resolveDatasetElementOptions. + * A derived controller may override this to resolve controller-specific options. + * The keys defined here are for backward compatibility for legend styles. + * @private + */ + _datasetElementOptions: [ + 'backgroundColor', + 'borderCapStyle', + 'borderColor', + 'borderDash', + 'borderDashOffset', + 'borderJoinStyle', + 'borderWidth', + ], + + /** + * Data element option keys to be resolved in _resolveDataElementOptions. + * A derived controller may override this to resolve controller-specific options. + * The keys defined here are for backward compatibility for legend styles. + * @private + */ + _dataElementOptions: ['backgroundColor', 'borderColor', 'borderWidth', 'pointStyle'], + + initialize: function(chart, datasetIndex) { + var me = this + me.chart = chart + me.index = datasetIndex + me.linkScales() + me.addElements() + me._type = me.getMeta().type + }, + + updateIndex: function(datasetIndex) { + this.index = datasetIndex + }, + + linkScales: function() { + var me = this + var meta = me.getMeta() + var chart = me.chart + var scales = chart.scales + var dataset = me.getDataset() + var scalesOpts = chart.options.scales + + if (meta.xAxisID === null || !(meta.xAxisID in scales) || dataset.xAxisID) { + meta.xAxisID = dataset.xAxisID || scalesOpts.xAxes[0].id + } + if (meta.yAxisID === null || !(meta.yAxisID in scales) || dataset.yAxisID) { + meta.yAxisID = dataset.yAxisID || scalesOpts.yAxes[0].id + } + }, + + getDataset: function() { + return this.chart.data.datasets[this.index] + }, + + getMeta: function() { + return this.chart.getDatasetMeta(this.index) + }, + + getScaleForId: function(scaleID) { + return this.chart.scales[scaleID] + }, + + /** + * @private + */ + _getValueScaleId: function() { + return this.getMeta().yAxisID + }, + + /** + * @private + */ + _getIndexScaleId: function() { + return this.getMeta().xAxisID + }, + + /** + * @private + */ + _getValueScale: function() { + return this.getScaleForId(this._getValueScaleId()) + }, + + /** + * @private + */ + _getIndexScale: function() { + return this.getScaleForId(this._getIndexScaleId()) + }, + + reset: function() { + this._update(true) + }, + + /** + * @private + */ + destroy: function() { + if (this._data) { + unlistenArrayEvents(this._data, this) + } + }, + + createMetaDataset: function() { + var me = this + var type = me.datasetElementType + return ( + type && + new type({ + _chart: me.chart, + _datasetIndex: me.index, + }) + ) + }, + + createMetaData: function(index) { + var me = this + var type = me.dataElementType + return ( + type && + new type({ + _chart: me.chart, + _datasetIndex: me.index, + _index: index, + }) + ) + }, + + addElements: function() { + var me = this + var meta = me.getMeta() + var data = me.getDataset().data || [] + var metaData = meta.data + var i, ilen + + for (i = 0, ilen = data.length; i < ilen; ++i) { + metaData[i] = metaData[i] || me.createMetaData(i) + } + + meta.dataset = meta.dataset || me.createMetaDataset() + }, + + addElementAndReset: function(index) { + var element = this.createMetaData(index) + this.getMeta().data.splice(index, 0, element) + this.updateElement(element, index, true) + }, + + buildOrUpdateElements: function() { + var me = this + var dataset = me.getDataset() + var data = dataset.data || (dataset.data = []) + + // In order to correctly handle data addition/deletion animation (an thus simulate + // real-time charts), we need to monitor these data modifications and synchronize + // the internal meta data accordingly. + if (me._data !== data) { + if (me._data) { + // This case happens when the user replaced the data array instance. + unlistenArrayEvents(me._data, me) + } + + if (data && Object.isExtensible(data)) { + listenArrayEvents(data, me) + } + me._data = data + } + + // Re-sync meta data in case the user replaced the data array or if we missed + // any updates and so make sure that we handle number of datapoints changing. + me.resyncElements() + }, + + /** + * Returns the merged user-supplied and default dataset-level options + * @private + */ + _configure: function() { + var me = this + me._config = helpers$1.merge({}, [me.chart.options.datasets[me._type], me.getDataset()], { + merger: function(key, target, source) { + if (key !== '_meta' && key !== 'data') { + helpers$1._merger(key, target, source) + } + }, + }) + }, + + _update: function(reset) { + var me = this + me._configure() + me._cachedDataOpts = null + me.update(reset) + }, + + update: helpers$1.noop, + + transition: function(easingValue) { + var meta = this.getMeta() + var elements = meta.data || [] + var ilen = elements.length + var i = 0 + + for (; i < ilen; ++i) { + elements[i].transition(easingValue) + } + + if (meta.dataset) { + meta.dataset.transition(easingValue) + } + }, + + draw: function() { + var meta = this.getMeta() + var elements = meta.data || [] + var ilen = elements.length + var i = 0 + + if (meta.dataset) { + meta.dataset.draw() + } + + for (; i < ilen; ++i) { + elements[i].draw() + } + }, + + /** + * Returns a set of predefined style properties that should be used to represent the dataset + * or the data if the index is specified + * @param {number} index - data index + * @return {IStyleInterface} style object + */ + getStyle: function(index) { + var me = this + var meta = me.getMeta() + var dataset = meta.dataset + var style + + me._configure() + if (dataset && index === undefined) { + style = me._resolveDatasetElementOptions(dataset || {}) + } else { + index = index || 0 + style = me._resolveDataElementOptions(meta.data[index] || {}, index) + } + + if (style.fill === false || style.fill === null) { + style.backgroundColor = style.borderColor + } + + return style + }, + + /** + * @private + */ + _resolveDatasetElementOptions: function(element, hover) { + var me = this + var chart = me.chart + var datasetOpts = me._config + var custom = element.custom || {} + var options = chart.options.elements[me.datasetElementType.prototype._type] || {} + var elementOptions = me._datasetElementOptions + var values = {} + var i, ilen, key, readKey + + // Scriptable options + var context = { + chart: chart, + dataset: me.getDataset(), + datasetIndex: me.index, + hover: hover, + } + + for (i = 0, ilen = elementOptions.length; i < ilen; ++i) { + key = elementOptions[i] + readKey = hover ? 'hover' + key.charAt(0).toUpperCase() + key.slice(1) : key + values[key] = resolve([custom[readKey], datasetOpts[readKey], options[readKey]], context) + } + + return values + }, + + /** + * @private + */ + _resolveDataElementOptions: function(element, index) { + var me = this + var custom = element && element.custom + var cached = me._cachedDataOpts + if (cached && !custom) { + return cached + } + var chart = me.chart + var datasetOpts = me._config + var options = chart.options.elements[me.dataElementType.prototype._type] || {} + var elementOptions = me._dataElementOptions + var values = {} + + // Scriptable options + var context = { + chart: chart, + dataIndex: index, + dataset: me.getDataset(), + datasetIndex: me.index, + } + + // `resolve` sets cacheable to `false` if any option is indexed or scripted + var info = { cacheable: !custom } + + var keys, i, ilen, key + + custom = custom || {} + + if (helpers$1.isArray(elementOptions)) { + for (i = 0, ilen = elementOptions.length; i < ilen; ++i) { + key = elementOptions[i] + values[key] = resolve([custom[key], datasetOpts[key], options[key]], context, index, info) + } + } else { + keys = Object.keys(elementOptions) + for (i = 0, ilen = keys.length; i < ilen; ++i) { + key = keys[i] + values[key] = resolve( + [custom[key], datasetOpts[elementOptions[key]], datasetOpts[key], options[key]], + context, + index, + info, + ) + } + } + + if (info.cacheable) { + me._cachedDataOpts = Object.freeze(values) + } + + return values + }, + + removeHoverStyle: function(element) { + helpers$1.merge(element._model, element.$previousStyle || {}) + delete element.$previousStyle + }, + + setHoverStyle: function(element) { + var dataset = this.chart.data.datasets[element._datasetIndex] + var index = element._index + var custom = element.custom || {} + var model = element._model + var getHoverColor = helpers$1.getHoverColor + + element.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + } + + model.backgroundColor = resolve( + [custom.hoverBackgroundColor, dataset.hoverBackgroundColor, getHoverColor(model.backgroundColor)], + undefined, + index, + ) + model.borderColor = resolve( + [custom.hoverBorderColor, dataset.hoverBorderColor, getHoverColor(model.borderColor)], + undefined, + index, + ) + model.borderWidth = resolve( + [custom.hoverBorderWidth, dataset.hoverBorderWidth, model.borderWidth], + undefined, + index, + ) + }, + + /** + * @private + */ + _removeDatasetHoverStyle: function() { + var element = this.getMeta().dataset + + if (element) { + this.removeHoverStyle(element) + } + }, + + /** + * @private + */ + _setDatasetHoverStyle: function() { + var element = this.getMeta().dataset + var prev = {} + var i, ilen, key, keys, hoverOptions, model + + if (!element) { + return + } + + model = element._model + hoverOptions = this._resolveDatasetElementOptions(element, true) + + keys = Object.keys(hoverOptions) + for (i = 0, ilen = keys.length; i < ilen; ++i) { + key = keys[i] + prev[key] = model[key] + model[key] = hoverOptions[key] + } + + element.$previousStyle = prev + }, + + /** + * @private + */ + resyncElements: function() { + var me = this + var meta = me.getMeta() + var data = me.getDataset().data + var numMeta = meta.data.length + var numData = data.length + + if (numData < numMeta) { + meta.data.splice(numData, numMeta - numData) + } else if (numData > numMeta) { + me.insertElements(numMeta, numData - numMeta) + } + }, + + /** + * @private + */ + insertElements: function(start, count) { + for (var i = 0; i < count; ++i) { + this.addElementAndReset(start + i) + } + }, + + /** + * @private + */ + onDataPush: function() { + var count = arguments.length + this.insertElements(this.getDataset().data.length - count, count) + }, + + /** + * @private + */ + onDataPop: function() { + this.getMeta().data.pop() + }, + + /** + * @private + */ + onDataShift: function() { + this.getMeta().data.shift() + }, + + /** + * @private + */ + onDataSplice: function(start, count) { + this.getMeta().data.splice(start, count) + this.insertElements(start, arguments.length - 2) + }, + + /** + * @private + */ + onDataUnshift: function() { + this.insertElements(0, arguments.length) + }, + }) + + DatasetController.extend = helpers$1.inherits + + var core_datasetController = DatasetController + + var TAU = Math.PI * 2 + + core_defaults._set('global', { + elements: { + arc: { + backgroundColor: core_defaults.global.defaultColor, + borderColor: '#fff', + borderWidth: 2, + borderAlign: 'center', + }, + }, + }) + + function clipArc(ctx, arc) { + var startAngle = arc.startAngle + var endAngle = arc.endAngle + var pixelMargin = arc.pixelMargin + var angleMargin = pixelMargin / arc.outerRadius + var x = arc.x + var y = arc.y + + // Draw an inner border by cliping the arc and drawing a double-width border + // Enlarge the clipping arc by 0.33 pixels to eliminate glitches between borders + ctx.beginPath() + ctx.arc(x, y, arc.outerRadius, startAngle - angleMargin, endAngle + angleMargin) + if (arc.innerRadius > pixelMargin) { + angleMargin = pixelMargin / arc.innerRadius + ctx.arc(x, y, arc.innerRadius - pixelMargin, endAngle + angleMargin, startAngle - angleMargin, true) + } else { + ctx.arc(x, y, pixelMargin, endAngle + Math.PI / 2, startAngle - Math.PI / 2) + } + ctx.closePath() + ctx.clip() + } + + function drawFullCircleBorders(ctx, vm, arc, inner) { + var endAngle = arc.endAngle + var i + + if (inner) { + arc.endAngle = arc.startAngle + TAU + clipArc(ctx, arc) + arc.endAngle = endAngle + if (arc.endAngle === arc.startAngle && arc.fullCircles) { + arc.endAngle += TAU + arc.fullCircles-- + } + } + + ctx.beginPath() + ctx.arc(arc.x, arc.y, arc.innerRadius, arc.startAngle + TAU, arc.startAngle, true) + for (i = 0; i < arc.fullCircles; ++i) { + ctx.stroke() + } + + ctx.beginPath() + ctx.arc(arc.x, arc.y, vm.outerRadius, arc.startAngle, arc.startAngle + TAU) + for (i = 0; i < arc.fullCircles; ++i) { + ctx.stroke() + } + } + + function drawBorder(ctx, vm, arc) { + var inner = vm.borderAlign === 'inner' + + if (inner) { + ctx.lineWidth = vm.borderWidth * 2 + ctx.lineJoin = 'round' + } else { + ctx.lineWidth = vm.borderWidth + ctx.lineJoin = 'bevel' + } + + if (arc.fullCircles) { + drawFullCircleBorders(ctx, vm, arc, inner) + } + + if (inner) { + clipArc(ctx, arc) + } + + ctx.beginPath() + ctx.arc(arc.x, arc.y, vm.outerRadius, arc.startAngle, arc.endAngle) + ctx.arc(arc.x, arc.y, arc.innerRadius, arc.endAngle, arc.startAngle, true) + ctx.closePath() + ctx.stroke() + } + + var element_arc = core_element.extend({ + _type: 'arc', + + inLabelRange: function(mouseX) { + var vm = this._view + + if (vm) { + return Math.pow(mouseX - vm.x, 2) < Math.pow(vm.radius + vm.hoverRadius, 2) + } + return false + }, + + inRange: function(chartX, chartY) { + var vm = this._view + + if (vm) { + var pointRelativePosition = helpers$1.getAngleFromPoint(vm, { x: chartX, y: chartY }) + var angle = pointRelativePosition.angle + var distance = pointRelativePosition.distance + + // Sanitise angle range + var startAngle = vm.startAngle + var endAngle = vm.endAngle + while (endAngle < startAngle) { + endAngle += TAU + } + while (angle > endAngle) { + angle -= TAU + } + while (angle < startAngle) { + angle += TAU + } + + // Check if within the range of the open/close angle + var betweenAngles = angle >= startAngle && angle <= endAngle + var withinRadius = distance >= vm.innerRadius && distance <= vm.outerRadius + + return betweenAngles && withinRadius + } + return false + }, + + getCenterPoint: function() { + var vm = this._view + var halfAngle = (vm.startAngle + vm.endAngle) / 2 + var halfRadius = (vm.innerRadius + vm.outerRadius) / 2 + return { + x: vm.x + Math.cos(halfAngle) * halfRadius, + y: vm.y + Math.sin(halfAngle) * halfRadius, + } + }, + + getArea: function() { + var vm = this._view + return ( + Math.PI * + ((vm.endAngle - vm.startAngle) / (2 * Math.PI)) * + (Math.pow(vm.outerRadius, 2) - Math.pow(vm.innerRadius, 2)) + ) + }, + + tooltipPosition: function() { + var vm = this._view + var centreAngle = vm.startAngle + (vm.endAngle - vm.startAngle) / 2 + var rangeFromCentre = (vm.outerRadius - vm.innerRadius) / 2 + vm.innerRadius + + return { + x: vm.x + Math.cos(centreAngle) * rangeFromCentre, + y: vm.y + Math.sin(centreAngle) * rangeFromCentre, + } + }, + + draw: function() { + var ctx = this._chart.ctx + var vm = this._view + var pixelMargin = vm.borderAlign === 'inner' ? 0.33 : 0 + var arc = { + x: vm.x, + y: vm.y, + innerRadius: vm.innerRadius, + outerRadius: Math.max(vm.outerRadius - pixelMargin, 0), + pixelMargin: pixelMargin, + startAngle: vm.startAngle, + endAngle: vm.endAngle, + fullCircles: Math.floor(vm.circumference / TAU), + } + var i + + ctx.save() + + ctx.fillStyle = vm.backgroundColor + ctx.strokeStyle = vm.borderColor + + if (arc.fullCircles) { + arc.endAngle = arc.startAngle + TAU + ctx.beginPath() + ctx.arc(arc.x, arc.y, arc.outerRadius, arc.startAngle, arc.endAngle) + ctx.arc(arc.x, arc.y, arc.innerRadius, arc.endAngle, arc.startAngle, true) + ctx.closePath() + for (i = 0; i < arc.fullCircles; ++i) { + ctx.fill() + } + arc.endAngle = arc.startAngle + (vm.circumference % TAU) + } + + ctx.beginPath() + ctx.arc(arc.x, arc.y, arc.outerRadius, arc.startAngle, arc.endAngle) + ctx.arc(arc.x, arc.y, arc.innerRadius, arc.endAngle, arc.startAngle, true) + ctx.closePath() + ctx.fill() + + if (vm.borderWidth) { + drawBorder(ctx, vm, arc) + } + + ctx.restore() + }, + }) + + var valueOrDefault$1 = helpers$1.valueOrDefault + + var defaultColor = core_defaults.global.defaultColor + + core_defaults._set('global', { + elements: { + line: { + tension: 0.4, + backgroundColor: defaultColor, + borderWidth: 3, + borderColor: defaultColor, + borderCapStyle: 'butt', + borderDash: [], + borderDashOffset: 0.0, + borderJoinStyle: 'miter', + capBezierPoints: true, + fill: true, // do we fill in the area between the line and its base axis + }, + }, + }) + + var element_line = core_element.extend({ + _type: 'line', + + draw: function() { + var me = this + var vm = me._view + var ctx = me._chart.ctx + var spanGaps = vm.spanGaps + var points = me._children.slice() // clone array + var globalDefaults = core_defaults.global + var globalOptionLineElements = globalDefaults.elements.line + var lastDrawnIndex = -1 + var closePath = me._loop + var index, previous, currentVM + + if (!points.length) { + return + } + + if (me._loop) { + for (index = 0; index < points.length; ++index) { + previous = helpers$1.previousItem(points, index) + // If the line has an open path, shift the point array + if (!points[index]._view.skip && previous._view.skip) { + points = points.slice(index).concat(points.slice(0, index)) + closePath = spanGaps + break + } + } + // If the line has a close path, add the first point again + if (closePath) { + points.push(points[0]) + } + } + + ctx.save() + + // Stroke Line Options + ctx.lineCap = vm.borderCapStyle || globalOptionLineElements.borderCapStyle + + // IE 9 and 10 do not support line dash + if (ctx.setLineDash) { + ctx.setLineDash(vm.borderDash || globalOptionLineElements.borderDash) + } + + ctx.lineDashOffset = valueOrDefault$1(vm.borderDashOffset, globalOptionLineElements.borderDashOffset) + ctx.lineJoin = vm.borderJoinStyle || globalOptionLineElements.borderJoinStyle + ctx.lineWidth = valueOrDefault$1(vm.borderWidth, globalOptionLineElements.borderWidth) + ctx.strokeStyle = vm.borderColor || globalDefaults.defaultColor + + // Stroke Line + ctx.beginPath() + + // First point moves to it's starting position no matter what + currentVM = points[0]._view + if (!currentVM.skip) { + ctx.moveTo(currentVM.x, currentVM.y) + lastDrawnIndex = 0 + } + + for (index = 1; index < points.length; ++index) { + currentVM = points[index]._view + previous = lastDrawnIndex === -1 ? helpers$1.previousItem(points, index) : points[lastDrawnIndex] + + if (!currentVM.skip) { + if ((lastDrawnIndex !== index - 1 && !spanGaps) || lastDrawnIndex === -1) { + // There was a gap and this is the first point after the gap + ctx.moveTo(currentVM.x, currentVM.y) + } else { + // Line to next point + helpers$1.canvas.lineTo(ctx, previous._view, currentVM) + } + lastDrawnIndex = index + } + } + + if (closePath) { + ctx.closePath() + } + + ctx.stroke() + ctx.restore() + }, + }) + + var valueOrDefault$2 = helpers$1.valueOrDefault + + var defaultColor$1 = core_defaults.global.defaultColor + + core_defaults._set('global', { + elements: { + point: { + radius: 3, + pointStyle: 'circle', + backgroundColor: defaultColor$1, + borderColor: defaultColor$1, + borderWidth: 1, + // Hover + hitRadius: 1, + hoverRadius: 4, + hoverBorderWidth: 1, + }, + }, + }) + + function xRange(mouseX) { + var vm = this._view + return vm ? Math.abs(mouseX - vm.x) < vm.radius + vm.hitRadius : false + } + + function yRange(mouseY) { + var vm = this._view + return vm ? Math.abs(mouseY - vm.y) < vm.radius + vm.hitRadius : false + } + + var element_point = core_element.extend({ + _type: 'point', + + inRange: function(mouseX, mouseY) { + var vm = this._view + return vm + ? Math.pow(mouseX - vm.x, 2) + Math.pow(mouseY - vm.y, 2) < Math.pow(vm.hitRadius + vm.radius, 2) + : false + }, + + inLabelRange: xRange, + inXRange: xRange, + inYRange: yRange, + + getCenterPoint: function() { + var vm = this._view + return { + x: vm.x, + y: vm.y, + } + }, + + getArea: function() { + return Math.PI * Math.pow(this._view.radius, 2) + }, + + tooltipPosition: function() { + var vm = this._view + return { + x: vm.x, + y: vm.y, + padding: vm.radius + vm.borderWidth, + } + }, + + draw: function(chartArea) { + var vm = this._view + var ctx = this._chart.ctx + var pointStyle = vm.pointStyle + var rotation = vm.rotation + var radius = vm.radius + var x = vm.x + var y = vm.y + var globalDefaults = core_defaults.global + var defaultColor = globalDefaults.defaultColor // eslint-disable-line no-shadow + + if (vm.skip) { + return + } + + // Clipping for Points. + if (chartArea === undefined || helpers$1.canvas._isPointInArea(vm, chartArea)) { + ctx.strokeStyle = vm.borderColor || defaultColor + ctx.lineWidth = valueOrDefault$2(vm.borderWidth, globalDefaults.elements.point.borderWidth) + ctx.fillStyle = vm.backgroundColor || defaultColor + helpers$1.canvas.drawPoint(ctx, pointStyle, radius, x, y, rotation) + } + }, + }) + + var defaultColor$2 = core_defaults.global.defaultColor + + core_defaults._set('global', { + elements: { + rectangle: { + backgroundColor: defaultColor$2, + borderColor: defaultColor$2, + borderSkipped: 'bottom', + borderWidth: 0, + }, + }, + }) + + function isVertical(vm) { + return vm && vm.width !== undefined + } + + /** + * Helper function to get the bounds of the bar regardless of the orientation + * @param bar {Chart.Element.Rectangle} the bar + * @return {Bounds} bounds of the bar + * @private + */ + function getBarBounds(vm) { + var x1, x2, y1, y2, half + + if (isVertical(vm)) { + half = vm.width / 2 + x1 = vm.x - half + x2 = vm.x + half + y1 = Math.min(vm.y, vm.base) + y2 = Math.max(vm.y, vm.base) + } else { + half = vm.height / 2 + x1 = Math.min(vm.x, vm.base) + x2 = Math.max(vm.x, vm.base) + y1 = vm.y - half + y2 = vm.y + half + } + + return { + left: x1, + top: y1, + right: x2, + bottom: y2, + } + } + + function swap(orig, v1, v2) { + return orig === v1 ? v2 : orig === v2 ? v1 : orig + } + + function parseBorderSkipped(vm) { + var edge = vm.borderSkipped + var res = {} + + if (!edge) { + return res + } + + if (vm.horizontal) { + if (vm.base > vm.x) { + edge = swap(edge, 'left', 'right') + } + } else if (vm.base < vm.y) { + edge = swap(edge, 'bottom', 'top') + } + + res[edge] = true + return res + } + + function parseBorderWidth(vm, maxW, maxH) { + var value = vm.borderWidth + var skip = parseBorderSkipped(vm) + var t, r, b, l + + if (helpers$1.isObject(value)) { + t = +value.top || 0 + r = +value.right || 0 + b = +value.bottom || 0 + l = +value.left || 0 + } else { + t = r = b = l = +value || 0 + } + + return { + t: skip.top || t < 0 ? 0 : t > maxH ? maxH : t, + r: skip.right || r < 0 ? 0 : r > maxW ? maxW : r, + b: skip.bottom || b < 0 ? 0 : b > maxH ? maxH : b, + l: skip.left || l < 0 ? 0 : l > maxW ? maxW : l, + } + } + + function boundingRects(vm) { + var bounds = getBarBounds(vm) + var width = bounds.right - bounds.left + var height = bounds.bottom - bounds.top + var border = parseBorderWidth(vm, width / 2, height / 2) + + return { + outer: { + x: bounds.left, + y: bounds.top, + w: width, + h: height, + }, + inner: { + x: bounds.left + border.l, + y: bounds.top + border.t, + w: width - border.l - border.r, + h: height - border.t - border.b, + }, + } + } + + function inRange(vm, x, y) { + var skipX = x === null + var skipY = y === null + var bounds = !vm || (skipX && skipY) ? false : getBarBounds(vm) + + return ( + bounds && (skipX || (x >= bounds.left && x <= bounds.right)) && (skipY || (y >= bounds.top && y <= bounds.bottom)) + ) + } + + var element_rectangle = core_element.extend({ + _type: 'rectangle', + + draw: function() { + var ctx = this._chart.ctx + var vm = this._view + var rects = boundingRects(vm) + var outer = rects.outer + var inner = rects.inner + + ctx.fillStyle = vm.backgroundColor + ctx.fillRect(outer.x, outer.y, outer.w, outer.h) + + if (outer.w === inner.w && outer.h === inner.h) { + return + } + + ctx.save() + ctx.beginPath() + ctx.rect(outer.x, outer.y, outer.w, outer.h) + ctx.clip() + ctx.fillStyle = vm.borderColor + ctx.rect(inner.x, inner.y, inner.w, inner.h) + ctx.fill('evenodd') + ctx.restore() + }, + + height: function() { + var vm = this._view + return vm.base - vm.y + }, + + inRange: function(mouseX, mouseY) { + return inRange(this._view, mouseX, mouseY) + }, + + inLabelRange: function(mouseX, mouseY) { + var vm = this._view + return isVertical(vm) ? inRange(vm, mouseX, null) : inRange(vm, null, mouseY) + }, + + inXRange: function(mouseX) { + return inRange(this._view, mouseX, null) + }, + + inYRange: function(mouseY) { + return inRange(this._view, null, mouseY) + }, + + getCenterPoint: function() { + var vm = this._view + var x, y + if (isVertical(vm)) { + x = vm.x + y = (vm.y + vm.base) / 2 + } else { + x = (vm.x + vm.base) / 2 + y = vm.y + } + + return { x: x, y: y } + }, + + getArea: function() { + var vm = this._view + + return isVertical(vm) ? vm.width * Math.abs(vm.y - vm.base) : vm.height * Math.abs(vm.x - vm.base) + }, + + tooltipPosition: function() { + var vm = this._view + return { + x: vm.x, + y: vm.y, + } + }, + }) + + var elements = {} + var Arc = element_arc + var Line = element_line + var Point = element_point + var Rectangle = element_rectangle + elements.Arc = Arc + elements.Line = Line + elements.Point = Point + elements.Rectangle = Rectangle + + var deprecated = helpers$1._deprecated + var valueOrDefault$3 = helpers$1.valueOrDefault + + core_defaults._set('bar', { + hover: { + mode: 'label', + }, + + scales: { + xAxes: [ + { + type: 'category', + offset: true, + gridLines: { + offsetGridLines: true, + }, + }, + ], + + yAxes: [ + { + type: 'linear', + }, + ], + }, + }) + + core_defaults._set('global', { + datasets: { + bar: { + categoryPercentage: 0.8, + barPercentage: 0.9, + }, + }, + }) + + /** + * Computes the "optimal" sample size to maintain bars equally sized while preventing overlap. + * @private + */ + function computeMinSampleSize(scale, pixels) { + var min = scale._length + var prev, curr, i, ilen + + for (i = 1, ilen = pixels.length; i < ilen; ++i) { + min = Math.min(min, Math.abs(pixels[i] - pixels[i - 1])) + } + + for (i = 0, ilen = scale.getTicks().length; i < ilen; ++i) { + curr = scale.getPixelForTick(i) + min = i > 0 ? Math.min(min, Math.abs(curr - prev)) : min + prev = curr + } + + return min + } + + /** + * Computes an "ideal" category based on the absolute bar thickness or, if undefined or null, + * uses the smallest interval (see computeMinSampleSize) that prevents bar overlapping. This + * mode currently always generates bars equally sized (until we introduce scriptable options?). + * @private + */ + function computeFitCategoryTraits(index, ruler, options) { + var thickness = options.barThickness + var count = ruler.stackCount + var curr = ruler.pixels[index] + var min = helpers$1.isNullOrUndef(thickness) ? computeMinSampleSize(ruler.scale, ruler.pixels) : -1 + var size, ratio + + if (helpers$1.isNullOrUndef(thickness)) { + size = min * options.categoryPercentage + ratio = options.barPercentage + } else { + // When bar thickness is enforced, category and bar percentages are ignored. + // Note(SB): we could add support for relative bar thickness (e.g. barThickness: '50%') + // and deprecate barPercentage since this value is ignored when thickness is absolute. + size = thickness * count + ratio = 1 + } + + return { + chunk: size / count, + ratio: ratio, + start: curr - size / 2, + } + } + + /** + * Computes an "optimal" category that globally arranges bars side by side (no gap when + * percentage options are 1), based on the previous and following categories. This mode + * generates bars with different widths when data are not evenly spaced. + * @private + */ + function computeFlexCategoryTraits(index, ruler, options) { + var pixels = ruler.pixels + var curr = pixels[index] + var prev = index > 0 ? pixels[index - 1] : null + var next = index < pixels.length - 1 ? pixels[index + 1] : null + var percent = options.categoryPercentage + var start, size + + if (prev === null) { + // first data: its size is double based on the next point or, + // if it's also the last data, we use the scale size. + prev = curr - (next === null ? ruler.end - ruler.start : next - curr) + } + + if (next === null) { + // last data: its size is also double based on the previous point. + next = curr + curr - prev + } + + start = curr - ((curr - Math.min(prev, next)) / 2) * percent + size = (Math.abs(next - prev) / 2) * percent + + return { + chunk: size / ruler.stackCount, + ratio: options.barPercentage, + start: start, + } + } + + var controller_bar = core_datasetController.extend({ + dataElementType: elements.Rectangle, + + /** + * @private + */ + _dataElementOptions: [ + 'backgroundColor', + 'borderColor', + 'borderSkipped', + 'borderWidth', + 'barPercentage', + 'barThickness', + 'categoryPercentage', + 'maxBarThickness', + 'minBarLength', + ], + + initialize: function() { + var me = this + var meta, scaleOpts + + core_datasetController.prototype.initialize.apply(me, arguments) + + meta = me.getMeta() + meta.stack = me.getDataset().stack + meta.bar = true + + scaleOpts = me._getIndexScale().options + deprecated('bar chart', scaleOpts.barPercentage, 'scales.[x/y]Axes.barPercentage', 'dataset.barPercentage') + deprecated('bar chart', scaleOpts.barThickness, 'scales.[x/y]Axes.barThickness', 'dataset.barThickness') + deprecated( + 'bar chart', + scaleOpts.categoryPercentage, + 'scales.[x/y]Axes.categoryPercentage', + 'dataset.categoryPercentage', + ) + deprecated( + 'bar chart', + me._getValueScale().options.minBarLength, + 'scales.[x/y]Axes.minBarLength', + 'dataset.minBarLength', + ) + deprecated('bar chart', scaleOpts.maxBarThickness, 'scales.[x/y]Axes.maxBarThickness', 'dataset.maxBarThickness') + }, + + update: function(reset) { + var me = this + var rects = me.getMeta().data + var i, ilen + + me._ruler = me.getRuler() + + for (i = 0, ilen = rects.length; i < ilen; ++i) { + me.updateElement(rects[i], i, reset) + } + }, + + updateElement: function(rectangle, index, reset) { + var me = this + var meta = me.getMeta() + var dataset = me.getDataset() + var options = me._resolveDataElementOptions(rectangle, index) + + rectangle._xScale = me.getScaleForId(meta.xAxisID) + rectangle._yScale = me.getScaleForId(meta.yAxisID) + rectangle._datasetIndex = me.index + rectangle._index = index + rectangle._model = { + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderSkipped: options.borderSkipped, + borderWidth: options.borderWidth, + datasetLabel: dataset.label, + label: me.chart.data.labels[index], + } + + if (helpers$1.isArray(dataset.data[index])) { + rectangle._model.borderSkipped = null + } + + me._updateElementGeometry(rectangle, index, reset, options) + + rectangle.pivot() + }, + + /** + * @private + */ + _updateElementGeometry: function(rectangle, index, reset, options) { + var me = this + var model = rectangle._model + var vscale = me._getValueScale() + var base = vscale.getBasePixel() + var horizontal = vscale.isHorizontal() + var ruler = me._ruler || me.getRuler() + var vpixels = me.calculateBarValuePixels(me.index, index, options) + var ipixels = me.calculateBarIndexPixels(me.index, index, ruler, options) + + model.horizontal = horizontal + model.base = reset ? base : vpixels.base + model.x = horizontal ? (reset ? base : vpixels.head) : ipixels.center + model.y = horizontal ? ipixels.center : reset ? base : vpixels.head + model.height = horizontal ? ipixels.size : undefined + model.width = horizontal ? undefined : ipixels.size + }, + + /** + * Returns the stacks based on groups and bar visibility. + * @param {number} [last] - The dataset index + * @returns {string[]} The list of stack IDs + * @private + */ + _getStacks: function(last) { + var me = this + var scale = me._getIndexScale() + var metasets = scale._getMatchingVisibleMetas(me._type) + var stacked = scale.options.stacked + var ilen = metasets.length + var stacks = [] + var i, meta + + for (i = 0; i < ilen; ++i) { + meta = metasets[i] + // stacked | meta.stack + // | found | not found | undefined + // false | x | x | x + // true | | x | + // undefined | | x | x + if ( + stacked === false || + stacks.indexOf(meta.stack) === -1 || + (stacked === undefined && meta.stack === undefined) + ) { + stacks.push(meta.stack) + } + if (meta.index === last) { + break + } + } + + return stacks + }, + + /** + * Returns the effective number of stacks based on groups and bar visibility. + * @private + */ + getStackCount: function() { + return this._getStacks().length + }, + + /** + * Returns the stack index for the given dataset based on groups and bar visibility. + * @param {number} [datasetIndex] - The dataset index + * @param {string} [name] - The stack name to find + * @returns {number} The stack index + * @private + */ + getStackIndex: function(datasetIndex, name) { + var stacks = this._getStacks(datasetIndex) + var index = name !== undefined ? stacks.indexOf(name) : -1 // indexOf returns -1 if element is not present + + return index === -1 ? stacks.length - 1 : index + }, + + /** + * @private + */ + getRuler: function() { + var me = this + var scale = me._getIndexScale() + var pixels = [] + var i, ilen + + for (i = 0, ilen = me.getMeta().data.length; i < ilen; ++i) { + pixels.push(scale.getPixelForValue(null, i, me.index)) + } + + return { + pixels: pixels, + start: scale._startPixel, + end: scale._endPixel, + stackCount: me.getStackCount(), + scale: scale, + } + }, + + /** + * Note: pixel values are not clamped to the scale area. + * @private + */ + calculateBarValuePixels: function(datasetIndex, index, options) { + var me = this + var chart = me.chart + var scale = me._getValueScale() + var isHorizontal = scale.isHorizontal() + var datasets = chart.data.datasets + var metasets = scale._getMatchingVisibleMetas(me._type) + var value = scale._parseValue(datasets[datasetIndex].data[index]) + var minBarLength = options.minBarLength + var stacked = scale.options.stacked + var stack = me.getMeta().stack + var start = value.start === undefined ? 0 : value.max >= 0 && value.min >= 0 ? value.min : value.max + var length = + value.start === undefined + ? value.end + : value.max >= 0 && value.min >= 0 + ? value.max - value.min + : value.min - value.max + var ilen = metasets.length + var i, imeta, ivalue, base, head, size, stackLength + + if (stacked || (stacked === undefined && stack !== undefined)) { + for (i = 0; i < ilen; ++i) { + imeta = metasets[i] + + if (imeta.index === datasetIndex) { + break + } + + if (imeta.stack === stack) { + stackLength = scale._parseValue(datasets[imeta.index].data[index]) + ivalue = + stackLength.start === undefined + ? stackLength.end + : stackLength.min >= 0 && stackLength.max >= 0 + ? stackLength.max + : stackLength.min + + if ((value.min < 0 && ivalue < 0) || (value.max >= 0 && ivalue > 0)) { + start += ivalue + } + } + } + } + + base = scale.getPixelForValue(start) + head = scale.getPixelForValue(start + length) + size = head - base + + if (minBarLength !== undefined && Math.abs(size) < minBarLength) { + size = minBarLength + if ((length >= 0 && !isHorizontal) || (length < 0 && isHorizontal)) { + head = base - minBarLength + } else { + head = base + minBarLength + } + } + + return { + size: size, + base: base, + head: head, + center: head + size / 2, + } + }, + + /** + * @private + */ + calculateBarIndexPixels: function(datasetIndex, index, ruler, options) { + var me = this + var range = + options.barThickness === 'flex' + ? computeFlexCategoryTraits(index, ruler, options) + : computeFitCategoryTraits(index, ruler, options) + + var stackIndex = me.getStackIndex(datasetIndex, me.getMeta().stack) + var center = range.start + range.chunk * stackIndex + range.chunk / 2 + var size = Math.min(valueOrDefault$3(options.maxBarThickness, Infinity), range.chunk * range.ratio) + + return { + base: center - size / 2, + head: center + size / 2, + center: center, + size: size, + } + }, + + draw: function() { + var me = this + var chart = me.chart + var scale = me._getValueScale() + var rects = me.getMeta().data + var dataset = me.getDataset() + var ilen = rects.length + var i = 0 + + helpers$1.canvas.clipArea(chart.ctx, chart.chartArea) + + for (; i < ilen; ++i) { + var val = scale._parseValue(dataset.data[i]) + if (!isNaN(val.min) && !isNaN(val.max)) { + rects[i].draw() + } + } + + helpers$1.canvas.unclipArea(chart.ctx) + }, + + /** + * @private + */ + _resolveDataElementOptions: function() { + var me = this + var values = helpers$1.extend( + {}, + core_datasetController.prototype._resolveDataElementOptions.apply(me, arguments), + ) + var indexOpts = me._getIndexScale().options + var valueOpts = me._getValueScale().options + + values.barPercentage = valueOrDefault$3(indexOpts.barPercentage, values.barPercentage) + values.barThickness = valueOrDefault$3(indexOpts.barThickness, values.barThickness) + values.categoryPercentage = valueOrDefault$3(indexOpts.categoryPercentage, values.categoryPercentage) + values.maxBarThickness = valueOrDefault$3(indexOpts.maxBarThickness, values.maxBarThickness) + values.minBarLength = valueOrDefault$3(valueOpts.minBarLength, values.minBarLength) + + return values + }, + }) + + var valueOrDefault$4 = helpers$1.valueOrDefault + var resolve$1 = helpers$1.options.resolve + + core_defaults._set('bubble', { + hover: { + mode: 'single', + }, + + scales: { + xAxes: [ + { + type: 'linear', // bubble should probably use a linear scale by default + position: 'bottom', + id: 'x-axis-0', // need an ID so datasets can reference the scale + }, + ], + yAxes: [ + { + type: 'linear', + position: 'left', + id: 'y-axis-0', + }, + ], + }, + + tooltips: { + callbacks: { + title: function() { + // Title doesn't make sense for scatter since we format the data as a point + return '' + }, + label: function(item, data) { + var datasetLabel = data.datasets[item.datasetIndex].label || '' + var dataPoint = data.datasets[item.datasetIndex].data[item.index] + return datasetLabel + ': (' + item.xLabel + ', ' + item.yLabel + ', ' + dataPoint.r + ')' + }, + }, + }, + }) + + var controller_bubble = core_datasetController.extend({ + /** + * @protected + */ + dataElementType: elements.Point, + + /** + * @private + */ + _dataElementOptions: [ + 'backgroundColor', + 'borderColor', + 'borderWidth', + 'hoverBackgroundColor', + 'hoverBorderColor', + 'hoverBorderWidth', + 'hoverRadius', + 'hitRadius', + 'pointStyle', + 'rotation', + ], + + /** + * @protected + */ + update: function(reset) { + var me = this + var meta = me.getMeta() + var points = meta.data + + // Update Points + helpers$1.each(points, function(point, index) { + me.updateElement(point, index, reset) + }) + }, + + /** + * @protected + */ + updateElement: function(point, index, reset) { + var me = this + var meta = me.getMeta() + var custom = point.custom || {} + var xScale = me.getScaleForId(meta.xAxisID) + var yScale = me.getScaleForId(meta.yAxisID) + var options = me._resolveDataElementOptions(point, index) + var data = me.getDataset().data[index] + var dsIndex = me.index + + var x = reset + ? xScale.getPixelForDecimal(0.5) + : xScale.getPixelForValue(typeof data === 'object' ? data : NaN, index, dsIndex) + var y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(data, index, dsIndex) + + point._xScale = xScale + point._yScale = yScale + point._options = options + point._datasetIndex = dsIndex + point._index = index + point._model = { + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderWidth: options.borderWidth, + hitRadius: options.hitRadius, + pointStyle: options.pointStyle, + rotation: options.rotation, + radius: reset ? 0 : options.radius, + skip: custom.skip || isNaN(x) || isNaN(y), + x: x, + y: y, + } + + point.pivot() + }, + + /** + * @protected + */ + setHoverStyle: function(point) { + var model = point._model + var options = point._options + var getHoverColor = helpers$1.getHoverColor + + point.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + radius: model.radius, + } + + model.backgroundColor = valueOrDefault$4(options.hoverBackgroundColor, getHoverColor(options.backgroundColor)) + model.borderColor = valueOrDefault$4(options.hoverBorderColor, getHoverColor(options.borderColor)) + model.borderWidth = valueOrDefault$4(options.hoverBorderWidth, options.borderWidth) + model.radius = options.radius + options.hoverRadius + }, + + /** + * @private + */ + _resolveDataElementOptions: function(point, index) { + var me = this + var chart = me.chart + var dataset = me.getDataset() + var custom = point.custom || {} + var data = dataset.data[index] || {} + var values = core_datasetController.prototype._resolveDataElementOptions.apply(me, arguments) + + // Scriptable options + var context = { + chart: chart, + dataIndex: index, + dataset: dataset, + datasetIndex: me.index, + } + + // In case values were cached (and thus frozen), we need to clone the values + if (me._cachedDataOpts === values) { + values = helpers$1.extend({}, values) + } + + // Custom radius resolution + values.radius = resolve$1( + [custom.radius, data.r, me._config.radius, chart.options.elements.point.radius], + context, + index, + ) + + return values + }, + }) + + var valueOrDefault$5 = helpers$1.valueOrDefault + + var PI$1 = Math.PI + var DOUBLE_PI$1 = PI$1 * 2 + var HALF_PI$1 = PI$1 / 2 + + core_defaults._set('doughnut', { + animation: { + // Boolean - Whether we animate the rotation of the Doughnut + animateRotate: true, + // Boolean - Whether we animate scaling the Doughnut from the centre + animateScale: false, + }, + hover: { + mode: 'single', + }, + legendCallback: function(chart) { + var list = document.createElement('ul') + var data = chart.data + var datasets = data.datasets + var labels = data.labels + var i, ilen, listItem, listItemSpan + + list.setAttribute('class', chart.id + '-legend') + if (datasets.length) { + for (i = 0, ilen = datasets[0].data.length; i < ilen; ++i) { + listItem = list.appendChild(document.createElement('li')) + listItemSpan = listItem.appendChild(document.createElement('span')) + listItemSpan.style.backgroundColor = datasets[0].backgroundColor[i] + if (labels[i]) { + listItem.appendChild(document.createTextNode(labels[i])) + } + } + } + + return list.outerHTML + }, + legend: { + labels: { + generateLabels: function(chart) { + var data = chart.data + if (data.labels.length && data.datasets.length) { + return data.labels.map(function(label, i) { + var meta = chart.getDatasetMeta(0) + var style = meta.controller.getStyle(i) + + return { + text: label, + fillStyle: style.backgroundColor, + strokeStyle: style.borderColor, + lineWidth: style.borderWidth, + hidden: isNaN(data.datasets[0].data[i]) || meta.data[i].hidden, + + // Extra data used for toggling the correct item + index: i, + } + }) + } + return [] + }, + }, + + onClick: function(e, legendItem) { + var index = legendItem.index + var chart = this.chart + var i, ilen, meta + + for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) { + meta = chart.getDatasetMeta(i) + // toggle visibility of index if exists + if (meta.data[index]) { + meta.data[index].hidden = !meta.data[index].hidden + } + } + + chart.update() + }, + }, + + // The percentage of the chart that we cut out of the middle. + cutoutPercentage: 50, + + // The rotation of the chart, where the first data arc begins. + rotation: -HALF_PI$1, + + // The total circumference of the chart. + circumference: DOUBLE_PI$1, + + // Need to override these to give a nice default + tooltips: { + callbacks: { + title: function() { + return '' + }, + label: function(tooltipItem, data) { + var dataLabel = data.labels[tooltipItem.index] + var value = ': ' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index] + + if (helpers$1.isArray(dataLabel)) { + // show value on first line of multiline label + // need to clone because we are changing the value + dataLabel = dataLabel.slice() + dataLabel[0] += value + } else { + dataLabel += value + } + + return dataLabel + }, + }, + }, + }) + + var controller_doughnut = core_datasetController.extend({ + dataElementType: elements.Arc, + + linkScales: helpers$1.noop, + + /** + * @private + */ + _dataElementOptions: [ + 'backgroundColor', + 'borderColor', + 'borderWidth', + 'borderAlign', + 'hoverBackgroundColor', + 'hoverBorderColor', + 'hoverBorderWidth', + ], + + // Get index of the dataset in relation to the visible datasets. This allows determining the inner and outer radius correctly + getRingIndex: function(datasetIndex) { + var ringIndex = 0 + + for (var j = 0; j < datasetIndex; ++j) { + if (this.chart.isDatasetVisible(j)) { + ++ringIndex + } + } + + return ringIndex + }, + + update: function(reset) { + var me = this + var chart = me.chart + var chartArea = chart.chartArea + var opts = chart.options + var ratioX = 1 + var ratioY = 1 + var offsetX = 0 + var offsetY = 0 + var meta = me.getMeta() + var arcs = meta.data + var cutout = opts.cutoutPercentage / 100 || 0 + var circumference = opts.circumference + var chartWeight = me._getRingWeight(me.index) + var maxWidth, maxHeight, i, ilen + + // If the chart's circumference isn't a full circle, calculate size as a ratio of the width/height of the arc + if (circumference < DOUBLE_PI$1) { + var startAngle = opts.rotation % DOUBLE_PI$1 + startAngle += startAngle >= PI$1 ? -DOUBLE_PI$1 : startAngle < -PI$1 ? DOUBLE_PI$1 : 0 + var endAngle = startAngle + circumference + var startX = Math.cos(startAngle) + var startY = Math.sin(startAngle) + var endX = Math.cos(endAngle) + var endY = Math.sin(endAngle) + var contains0 = (startAngle <= 0 && endAngle >= 0) || endAngle >= DOUBLE_PI$1 + var contains90 = (startAngle <= HALF_PI$1 && endAngle >= HALF_PI$1) || endAngle >= DOUBLE_PI$1 + HALF_PI$1 + var contains180 = startAngle === -PI$1 || endAngle >= PI$1 + var contains270 = (startAngle <= -HALF_PI$1 && endAngle >= -HALF_PI$1) || endAngle >= PI$1 + HALF_PI$1 + var minX = contains180 ? -1 : Math.min(startX, startX * cutout, endX, endX * cutout) + var minY = contains270 ? -1 : Math.min(startY, startY * cutout, endY, endY * cutout) + var maxX = contains0 ? 1 : Math.max(startX, startX * cutout, endX, endX * cutout) + var maxY = contains90 ? 1 : Math.max(startY, startY * cutout, endY, endY * cutout) + ratioX = (maxX - minX) / 2 + ratioY = (maxY - minY) / 2 + offsetX = -(maxX + minX) / 2 + offsetY = -(maxY + minY) / 2 + } + + for (i = 0, ilen = arcs.length; i < ilen; ++i) { + arcs[i]._options = me._resolveDataElementOptions(arcs[i], i) + } + + chart.borderWidth = me.getMaxBorderWidth() + maxWidth = (chartArea.right - chartArea.left - chart.borderWidth) / ratioX + maxHeight = (chartArea.bottom - chartArea.top - chart.borderWidth) / ratioY + chart.outerRadius = Math.max(Math.min(maxWidth, maxHeight) / 2, 0) + chart.innerRadius = Math.max(chart.outerRadius * cutout, 0) + chart.radiusLength = (chart.outerRadius - chart.innerRadius) / (me._getVisibleDatasetWeightTotal() || 1) + chart.offsetX = offsetX * chart.outerRadius + chart.offsetY = offsetY * chart.outerRadius + + meta.total = me.calculateTotal() + + me.outerRadius = chart.outerRadius - chart.radiusLength * me._getRingWeightOffset(me.index) + me.innerRadius = Math.max(me.outerRadius - chart.radiusLength * chartWeight, 0) + + for (i = 0, ilen = arcs.length; i < ilen; ++i) { + me.updateElement(arcs[i], i, reset) + } + }, + + updateElement: function(arc, index, reset) { + var me = this + var chart = me.chart + var chartArea = chart.chartArea + var opts = chart.options + var animationOpts = opts.animation + var centerX = (chartArea.left + chartArea.right) / 2 + var centerY = (chartArea.top + chartArea.bottom) / 2 + var startAngle = opts.rotation // non reset case handled later + var endAngle = opts.rotation // non reset case handled later + var dataset = me.getDataset() + var circumference = + reset && animationOpts.animateRotate + ? 0 + : arc.hidden + ? 0 + : me.calculateCircumference(dataset.data[index]) * (opts.circumference / DOUBLE_PI$1) + var innerRadius = reset && animationOpts.animateScale ? 0 : me.innerRadius + var outerRadius = reset && animationOpts.animateScale ? 0 : me.outerRadius + var options = arc._options || {} + + helpers$1.extend(arc, { + // Utility + _datasetIndex: me.index, + _index: index, + + // Desired view properties + _model: { + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderWidth: options.borderWidth, + borderAlign: options.borderAlign, + x: centerX + chart.offsetX, + y: centerY + chart.offsetY, + startAngle: startAngle, + endAngle: endAngle, + circumference: circumference, + outerRadius: outerRadius, + innerRadius: innerRadius, + label: helpers$1.valueAtIndexOrDefault(dataset.label, index, chart.data.labels[index]), + }, + }) + + var model = arc._model + + // Set correct angles if not resetting + if (!reset || !animationOpts.animateRotate) { + if (index === 0) { + model.startAngle = opts.rotation + } else { + model.startAngle = me.getMeta().data[index - 1]._model.endAngle + } + + model.endAngle = model.startAngle + model.circumference + } + + arc.pivot() + }, + + calculateTotal: function() { + var dataset = this.getDataset() + var meta = this.getMeta() + var total = 0 + var value + + helpers$1.each(meta.data, function(element, index) { + value = dataset.data[index] + if (!isNaN(value) && !element.hidden) { + total += Math.abs(value) + } + }) + + /* if (total === 0) { + total = NaN; + }*/ + + return total + }, + + calculateCircumference: function(value) { + var total = this.getMeta().total + if (total > 0 && !isNaN(value)) { + return DOUBLE_PI$1 * (Math.abs(value) / total) + } + return 0 + }, + + // gets the max border or hover width to properly scale pie charts + getMaxBorderWidth: function(arcs) { + var me = this + var max = 0 + var chart = me.chart + var i, ilen, meta, arc, controller, options, borderWidth, hoverWidth + + if (!arcs) { + // Find the outmost visible dataset + for (i = 0, ilen = chart.data.datasets.length; i < ilen; ++i) { + if (chart.isDatasetVisible(i)) { + meta = chart.getDatasetMeta(i) + arcs = meta.data + if (i !== me.index) { + controller = meta.controller + } + break + } + } + } + + if (!arcs) { + return 0 + } + + for (i = 0, ilen = arcs.length; i < ilen; ++i) { + arc = arcs[i] + if (controller) { + controller._configure() + options = controller._resolveDataElementOptions(arc, i) + } else { + options = arc._options + } + if (options.borderAlign !== 'inner') { + borderWidth = options.borderWidth + hoverWidth = options.hoverBorderWidth + + max = borderWidth > max ? borderWidth : max + max = hoverWidth > max ? hoverWidth : max + } + } + return max + }, + + /** + * @protected + */ + setHoverStyle: function(arc) { + var model = arc._model + var options = arc._options + var getHoverColor = helpers$1.getHoverColor + + arc.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + } + + model.backgroundColor = valueOrDefault$5(options.hoverBackgroundColor, getHoverColor(options.backgroundColor)) + model.borderColor = valueOrDefault$5(options.hoverBorderColor, getHoverColor(options.borderColor)) + model.borderWidth = valueOrDefault$5(options.hoverBorderWidth, options.borderWidth) + }, + + /** + * Get radius length offset of the dataset in relation to the visible datasets weights. This allows determining the inner and outer radius correctly + * @private + */ + _getRingWeightOffset: function(datasetIndex) { + var ringWeightOffset = 0 + + for (var i = 0; i < datasetIndex; ++i) { + if (this.chart.isDatasetVisible(i)) { + ringWeightOffset += this._getRingWeight(i) + } + } + + return ringWeightOffset + }, + + /** + * @private + */ + _getRingWeight: function(dataSetIndex) { + return Math.max(valueOrDefault$5(this.chart.data.datasets[dataSetIndex].weight, 1), 0) + }, + + /** + * Returns the sum of all visibile data set weights. This value can be 0. + * @private + */ + _getVisibleDatasetWeightTotal: function() { + return this._getRingWeightOffset(this.chart.data.datasets.length) + }, + }) + + core_defaults._set('horizontalBar', { + hover: { + mode: 'index', + axis: 'y', + }, + + scales: { + xAxes: [ + { + type: 'linear', + position: 'bottom', + }, + ], + + yAxes: [ + { + type: 'category', + position: 'left', + offset: true, + gridLines: { + offsetGridLines: true, + }, + }, + ], + }, + + elements: { + rectangle: { + borderSkipped: 'left', + }, + }, + + tooltips: { + mode: 'index', + axis: 'y', + }, + }) + + core_defaults._set('global', { + datasets: { + horizontalBar: { + categoryPercentage: 0.8, + barPercentage: 0.9, + }, + }, + }) + + var controller_horizontalBar = controller_bar.extend({ + /** + * @private + */ + _getValueScaleId: function() { + return this.getMeta().xAxisID + }, + + /** + * @private + */ + _getIndexScaleId: function() { + return this.getMeta().yAxisID + }, + }) + + var valueOrDefault$6 = helpers$1.valueOrDefault + var resolve$2 = helpers$1.options.resolve + var isPointInArea = helpers$1.canvas._isPointInArea + + core_defaults._set('line', { + showLines: true, + spanGaps: false, + + hover: { + mode: 'label', + }, + + scales: { + xAxes: [ + { + type: 'category', + id: 'x-axis-0', + }, + ], + yAxes: [ + { + type: 'linear', + id: 'y-axis-0', + }, + ], + }, + }) + + function scaleClip(scale, halfBorderWidth) { + var tickOpts = (scale && scale.options.ticks) || {} + var reverse = tickOpts.reverse + var min = tickOpts.min === undefined ? halfBorderWidth : 0 + var max = tickOpts.max === undefined ? halfBorderWidth : 0 + return { + start: reverse ? max : min, + end: reverse ? min : max, + } + } + + function defaultClip(xScale, yScale, borderWidth) { + var halfBorderWidth = borderWidth / 2 + var x = scaleClip(xScale, halfBorderWidth) + var y = scaleClip(yScale, halfBorderWidth) + + return { + top: y.end, + right: x.end, + bottom: y.start, + left: x.start, + } + } + + function toClip(value) { + var t, r, b, l + + if (helpers$1.isObject(value)) { + t = value.top + r = value.right + b = value.bottom + l = value.left + } else { + t = r = b = l = value + } + + return { + top: t, + right: r, + bottom: b, + left: l, + } + } + + var controller_line = core_datasetController.extend({ + datasetElementType: elements.Line, + + dataElementType: elements.Point, + + /** + * @private + */ + _datasetElementOptions: [ + 'backgroundColor', + 'borderCapStyle', + 'borderColor', + 'borderDash', + 'borderDashOffset', + 'borderJoinStyle', + 'borderWidth', + 'cubicInterpolationMode', + 'fill', + ], + + /** + * @private + */ + _dataElementOptions: { + backgroundColor: 'pointBackgroundColor', + borderColor: 'pointBorderColor', + borderWidth: 'pointBorderWidth', + hitRadius: 'pointHitRadius', + hoverBackgroundColor: 'pointHoverBackgroundColor', + hoverBorderColor: 'pointHoverBorderColor', + hoverBorderWidth: 'pointHoverBorderWidth', + hoverRadius: 'pointHoverRadius', + pointStyle: 'pointStyle', + radius: 'pointRadius', + rotation: 'pointRotation', + }, + + update: function(reset) { + var me = this + var meta = me.getMeta() + var line = meta.dataset + var points = meta.data || [] + var options = me.chart.options + var config = me._config + var showLine = (me._showLine = valueOrDefault$6(config.showLine, options.showLines)) + var i, ilen + + me._xScale = me.getScaleForId(meta.xAxisID) + me._yScale = me.getScaleForId(meta.yAxisID) + + // Update Line + if (showLine) { + // Compatibility: If the properties are defined with only the old name, use those values + if (config.tension !== undefined && config.lineTension === undefined) { + config.lineTension = config.tension + } + + // Utility + line._scale = me._yScale + line._datasetIndex = me.index + // Data + line._children = points + // Model + line._model = me._resolveDatasetElementOptions(line) + + line.pivot() + } + + // Update Points + for (i = 0, ilen = points.length; i < ilen; ++i) { + me.updateElement(points[i], i, reset) + } + + if (showLine && line._model.tension !== 0) { + me.updateBezierControlPoints() + } + + // Now pivot the point for animation + for (i = 0, ilen = points.length; i < ilen; ++i) { + points[i].pivot() + } + }, + + updateElement: function(point, index, reset) { + var me = this + var meta = me.getMeta() + var custom = point.custom || {} + var dataset = me.getDataset() + var datasetIndex = me.index + var value = dataset.data[index] + var xScale = me._xScale + var yScale = me._yScale + var lineModel = meta.dataset._model + var x, y + + var options = me._resolveDataElementOptions(point, index) + + x = xScale.getPixelForValue(typeof value === 'object' ? value : NaN, index, datasetIndex) + y = reset ? yScale.getBasePixel() : me.calculatePointY(value, index, datasetIndex) + + // Utility + point._xScale = xScale + point._yScale = yScale + point._options = options + point._datasetIndex = datasetIndex + point._index = index + + // Desired view properties + point._model = { + x: x, + y: y, + skip: custom.skip || isNaN(x) || isNaN(y), + // Appearance + radius: options.radius, + pointStyle: options.pointStyle, + rotation: options.rotation, + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderWidth: options.borderWidth, + tension: valueOrDefault$6(custom.tension, lineModel ? lineModel.tension : 0), + steppedLine: lineModel ? lineModel.steppedLine : false, + // Tooltip + hitRadius: options.hitRadius, + } + }, + + /** + * @private + */ + _resolveDatasetElementOptions: function(element) { + var me = this + var config = me._config + var custom = element.custom || {} + var options = me.chart.options + var lineOptions = options.elements.line + var values = core_datasetController.prototype._resolveDatasetElementOptions.apply(me, arguments) + + // The default behavior of lines is to break at null values, according + // to https://github.com/chartjs/Chart.js/issues/2435#issuecomment-216718158 + // This option gives lines the ability to span gaps + values.spanGaps = valueOrDefault$6(config.spanGaps, options.spanGaps) + values.tension = valueOrDefault$6(config.lineTension, lineOptions.tension) + values.steppedLine = resolve$2([custom.steppedLine, config.steppedLine, lineOptions.stepped]) + values.clip = toClip(valueOrDefault$6(config.clip, defaultClip(me._xScale, me._yScale, values.borderWidth))) + + return values + }, + + calculatePointY: function(value, index, datasetIndex) { + var me = this + var chart = me.chart + var yScale = me._yScale + var sumPos = 0 + var sumNeg = 0 + var i, ds, dsMeta, stackedRightValue, rightValue, metasets, ilen + + if (yScale.options.stacked) { + rightValue = +yScale.getRightValue(value) + metasets = chart._getSortedVisibleDatasetMetas() + ilen = metasets.length + + for (i = 0; i < ilen; ++i) { + dsMeta = metasets[i] + if (dsMeta.index === datasetIndex) { + break + } + + ds = chart.data.datasets[dsMeta.index] + if (dsMeta.type === 'line' && dsMeta.yAxisID === yScale.id) { + stackedRightValue = +yScale.getRightValue(ds.data[index]) + if (stackedRightValue < 0) { + sumNeg += stackedRightValue || 0 + } else { + sumPos += stackedRightValue || 0 + } + } + } + + if (rightValue < 0) { + return yScale.getPixelForValue(sumNeg + rightValue) + } + return yScale.getPixelForValue(sumPos + rightValue) + } + return yScale.getPixelForValue(value) + }, + + updateBezierControlPoints: function() { + var me = this + var chart = me.chart + var meta = me.getMeta() + var lineModel = meta.dataset._model + var area = chart.chartArea + var points = meta.data || [] + var i, ilen, model, controlPoints + + // Only consider points that are drawn in case the spanGaps option is used + if (lineModel.spanGaps) { + points = points.filter(function(pt) { + return !pt._model.skip + }) + } + + function capControlPoint(pt, min, max) { + return Math.max(Math.min(pt, max), min) + } + + if (lineModel.cubicInterpolationMode === 'monotone') { + helpers$1.splineCurveMonotone(points) + } else { + for (i = 0, ilen = points.length; i < ilen; ++i) { + model = points[i]._model + controlPoints = helpers$1.splineCurve( + helpers$1.previousItem(points, i)._model, + model, + helpers$1.nextItem(points, i)._model, + lineModel.tension, + ) + model.controlPointPreviousX = controlPoints.previous.x + model.controlPointPreviousY = controlPoints.previous.y + model.controlPointNextX = controlPoints.next.x + model.controlPointNextY = controlPoints.next.y + } + } + + if (chart.options.elements.line.capBezierPoints) { + for (i = 0, ilen = points.length; i < ilen; ++i) { + model = points[i]._model + if (isPointInArea(model, area)) { + if (i > 0 && isPointInArea(points[i - 1]._model, area)) { + model.controlPointPreviousX = capControlPoint(model.controlPointPreviousX, area.left, area.right) + model.controlPointPreviousY = capControlPoint(model.controlPointPreviousY, area.top, area.bottom) + } + if (i < points.length - 1 && isPointInArea(points[i + 1]._model, area)) { + model.controlPointNextX = capControlPoint(model.controlPointNextX, area.left, area.right) + model.controlPointNextY = capControlPoint(model.controlPointNextY, area.top, area.bottom) + } + } + } + } + }, + + draw: function() { + var me = this + var chart = me.chart + var meta = me.getMeta() + var points = meta.data || [] + var area = chart.chartArea + var canvas = chart.canvas + var i = 0 + var ilen = points.length + var clip + + if (me._showLine) { + clip = meta.dataset._model.clip + + helpers$1.canvas.clipArea(chart.ctx, { + left: clip.left === false ? 0 : area.left - clip.left, + right: clip.right === false ? canvas.width : area.right + clip.right, + top: clip.top === false ? 0 : area.top - clip.top, + bottom: clip.bottom === false ? canvas.height : area.bottom + clip.bottom, + }) + + meta.dataset.draw() + + helpers$1.canvas.unclipArea(chart.ctx) + } + + // Draw the points + for (; i < ilen; ++i) { + points[i].draw(area) + } + }, + + /** + * @protected + */ + setHoverStyle: function(point) { + var model = point._model + var options = point._options + var getHoverColor = helpers$1.getHoverColor + + point.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + radius: model.radius, + } + + model.backgroundColor = valueOrDefault$6(options.hoverBackgroundColor, getHoverColor(options.backgroundColor)) + model.borderColor = valueOrDefault$6(options.hoverBorderColor, getHoverColor(options.borderColor)) + model.borderWidth = valueOrDefault$6(options.hoverBorderWidth, options.borderWidth) + model.radius = valueOrDefault$6(options.hoverRadius, options.radius) + }, + }) + + var resolve$3 = helpers$1.options.resolve + + core_defaults._set('polarArea', { + scale: { + type: 'radialLinear', + angleLines: { + display: false, + }, + gridLines: { + circular: true, + }, + pointLabels: { + display: false, + }, + ticks: { + beginAtZero: true, + }, + }, + + // Boolean - Whether to animate the rotation of the chart + animation: { + animateRotate: true, + animateScale: true, + }, + + startAngle: -0.5 * Math.PI, + legendCallback: function(chart) { + var list = document.createElement('ul') + var data = chart.data + var datasets = data.datasets + var labels = data.labels + var i, ilen, listItem, listItemSpan + + list.setAttribute('class', chart.id + '-legend') + if (datasets.length) { + for (i = 0, ilen = datasets[0].data.length; i < ilen; ++i) { + listItem = list.appendChild(document.createElement('li')) + listItemSpan = listItem.appendChild(document.createElement('span')) + listItemSpan.style.backgroundColor = datasets[0].backgroundColor[i] + if (labels[i]) { + listItem.appendChild(document.createTextNode(labels[i])) + } + } + } + + return list.outerHTML + }, + legend: { + labels: { + generateLabels: function(chart) { + var data = chart.data + if (data.labels.length && data.datasets.length) { + return data.labels.map(function(label, i) { + var meta = chart.getDatasetMeta(0) + var style = meta.controller.getStyle(i) + + return { + text: label, + fillStyle: style.backgroundColor, + strokeStyle: style.borderColor, + lineWidth: style.borderWidth, + hidden: isNaN(data.datasets[0].data[i]) || meta.data[i].hidden, + + // Extra data used for toggling the correct item + index: i, + } + }) + } + return [] + }, + }, + + onClick: function(e, legendItem) { + var index = legendItem.index + var chart = this.chart + var i, ilen, meta + + for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) { + meta = chart.getDatasetMeta(i) + meta.data[index].hidden = !meta.data[index].hidden + } + + chart.update() + }, + }, + + // Need to override these to give a nice default + tooltips: { + callbacks: { + title: function() { + return '' + }, + label: function(item, data) { + return data.labels[item.index] + ': ' + item.yLabel + }, + }, + }, + }) + + var controller_polarArea = core_datasetController.extend({ + dataElementType: elements.Arc, + + linkScales: helpers$1.noop, + + /** + * @private + */ + _dataElementOptions: [ + 'backgroundColor', + 'borderColor', + 'borderWidth', + 'borderAlign', + 'hoverBackgroundColor', + 'hoverBorderColor', + 'hoverBorderWidth', + ], + + /** + * @private + */ + _getIndexScaleId: function() { + return this.chart.scale.id + }, + + /** + * @private + */ + _getValueScaleId: function() { + return this.chart.scale.id + }, + + update: function(reset) { + var me = this + var dataset = me.getDataset() + var meta = me.getMeta() + var start = me.chart.options.startAngle || 0 + var starts = (me._starts = []) + var angles = (me._angles = []) + var arcs = meta.data + var i, ilen, angle + + me._updateRadius() + + meta.count = me.countVisibleElements() + + for (i = 0, ilen = dataset.data.length; i < ilen; i++) { + starts[i] = start + angle = me._computeAngle(i) + angles[i] = angle + start += angle + } + + for (i = 0, ilen = arcs.length; i < ilen; ++i) { + arcs[i]._options = me._resolveDataElementOptions(arcs[i], i) + me.updateElement(arcs[i], i, reset) + } + }, + + /** + * @private + */ + _updateRadius: function() { + var me = this + var chart = me.chart + var chartArea = chart.chartArea + var opts = chart.options + var minSize = Math.min(chartArea.right - chartArea.left, chartArea.bottom - chartArea.top) + + chart.outerRadius = Math.max(minSize / 2, 0) + chart.innerRadius = Math.max(opts.cutoutPercentage ? (chart.outerRadius / 100) * opts.cutoutPercentage : 1, 0) + chart.radiusLength = (chart.outerRadius - chart.innerRadius) / chart.getVisibleDatasetCount() + + me.outerRadius = chart.outerRadius - chart.radiusLength * me.index + me.innerRadius = me.outerRadius - chart.radiusLength + }, + + updateElement: function(arc, index, reset) { + var me = this + var chart = me.chart + var dataset = me.getDataset() + var opts = chart.options + var animationOpts = opts.animation + var scale = chart.scale + var labels = chart.data.labels + + var centerX = scale.xCenter + var centerY = scale.yCenter + + // var negHalfPI = -0.5 * Math.PI; + var datasetStartAngle = opts.startAngle + var distance = arc.hidden ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]) + var startAngle = me._starts[index] + var endAngle = startAngle + (arc.hidden ? 0 : me._angles[index]) + + var resetRadius = animationOpts.animateScale ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]) + var options = arc._options || {} + + helpers$1.extend(arc, { + // Utility + _datasetIndex: me.index, + _index: index, + _scale: scale, + + // Desired view properties + _model: { + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderWidth: options.borderWidth, + borderAlign: options.borderAlign, + x: centerX, + y: centerY, + innerRadius: 0, + outerRadius: reset ? resetRadius : distance, + startAngle: reset && animationOpts.animateRotate ? datasetStartAngle : startAngle, + endAngle: reset && animationOpts.animateRotate ? datasetStartAngle : endAngle, + label: helpers$1.valueAtIndexOrDefault(labels, index, labels[index]), + }, + }) + + arc.pivot() + }, + + countVisibleElements: function() { + var dataset = this.getDataset() + var meta = this.getMeta() + var count = 0 + + helpers$1.each(meta.data, function(element, index) { + if (!isNaN(dataset.data[index]) && !element.hidden) { + count++ + } + }) + + return count + }, + + /** + * @protected + */ + setHoverStyle: function(arc) { + var model = arc._model + var options = arc._options + var getHoverColor = helpers$1.getHoverColor + var valueOrDefault = helpers$1.valueOrDefault + + arc.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + } + + model.backgroundColor = valueOrDefault(options.hoverBackgroundColor, getHoverColor(options.backgroundColor)) + model.borderColor = valueOrDefault(options.hoverBorderColor, getHoverColor(options.borderColor)) + model.borderWidth = valueOrDefault(options.hoverBorderWidth, options.borderWidth) + }, + + /** + * @private + */ + _computeAngle: function(index) { + var me = this + var count = this.getMeta().count + var dataset = me.getDataset() + var meta = me.getMeta() + + if (isNaN(dataset.data[index]) || meta.data[index].hidden) { + return 0 + } + + // Scriptable options + var context = { + chart: me.chart, + dataIndex: index, + dataset: dataset, + datasetIndex: me.index, + } + + return resolve$3([me.chart.options.elements.arc.angle, (2 * Math.PI) / count], context, index) + }, + }) + + core_defaults._set('pie', helpers$1.clone(core_defaults.doughnut)) + core_defaults._set('pie', { + cutoutPercentage: 0, + }) + + // Pie charts are Doughnut chart with different defaults + var controller_pie = controller_doughnut + + var valueOrDefault$7 = helpers$1.valueOrDefault + + core_defaults._set('radar', { + spanGaps: false, + scale: { + type: 'radialLinear', + }, + elements: { + line: { + fill: 'start', + tension: 0, // no bezier in radar + }, + }, + }) + + var controller_radar = core_datasetController.extend({ + datasetElementType: elements.Line, + + dataElementType: elements.Point, + + linkScales: helpers$1.noop, + + /** + * @private + */ + _datasetElementOptions: [ + 'backgroundColor', + 'borderWidth', + 'borderColor', + 'borderCapStyle', + 'borderDash', + 'borderDashOffset', + 'borderJoinStyle', + 'fill', + ], + + /** + * @private + */ + _dataElementOptions: { + backgroundColor: 'pointBackgroundColor', + borderColor: 'pointBorderColor', + borderWidth: 'pointBorderWidth', + hitRadius: 'pointHitRadius', + hoverBackgroundColor: 'pointHoverBackgroundColor', + hoverBorderColor: 'pointHoverBorderColor', + hoverBorderWidth: 'pointHoverBorderWidth', + hoverRadius: 'pointHoverRadius', + pointStyle: 'pointStyle', + radius: 'pointRadius', + rotation: 'pointRotation', + }, + + /** + * @private + */ + _getIndexScaleId: function() { + return this.chart.scale.id + }, + + /** + * @private + */ + _getValueScaleId: function() { + return this.chart.scale.id + }, + + update: function(reset) { + var me = this + var meta = me.getMeta() + var line = meta.dataset + var points = meta.data || [] + var scale = me.chart.scale + var config = me._config + var i, ilen + + // Compatibility: If the properties are defined with only the old name, use those values + if (config.tension !== undefined && config.lineTension === undefined) { + config.lineTension = config.tension + } + + // Utility + line._scale = scale + line._datasetIndex = me.index + // Data + line._children = points + line._loop = true + // Model + line._model = me._resolveDatasetElementOptions(line) + + line.pivot() + + // Update Points + for (i = 0, ilen = points.length; i < ilen; ++i) { + me.updateElement(points[i], i, reset) + } + + // Update bezier control points + me.updateBezierControlPoints() + + // Now pivot the point for animation + for (i = 0, ilen = points.length; i < ilen; ++i) { + points[i].pivot() + } + }, + + updateElement: function(point, index, reset) { + var me = this + var custom = point.custom || {} + var dataset = me.getDataset() + var scale = me.chart.scale + var pointPosition = scale.getPointPositionForValue(index, dataset.data[index]) + var options = me._resolveDataElementOptions(point, index) + var lineModel = me.getMeta().dataset._model + var x = reset ? scale.xCenter : pointPosition.x + var y = reset ? scale.yCenter : pointPosition.y + + // Utility + point._scale = scale + point._options = options + point._datasetIndex = me.index + point._index = index + + // Desired view properties + point._model = { + x: x, // value not used in dataset scale, but we want a consistent API between scales + y: y, + skip: custom.skip || isNaN(x) || isNaN(y), + // Appearance + radius: options.radius, + pointStyle: options.pointStyle, + rotation: options.rotation, + backgroundColor: options.backgroundColor, + borderColor: options.borderColor, + borderWidth: options.borderWidth, + tension: valueOrDefault$7(custom.tension, lineModel ? lineModel.tension : 0), + + // Tooltip + hitRadius: options.hitRadius, + } + }, + + /** + * @private + */ + _resolveDatasetElementOptions: function() { + var me = this + var config = me._config + var options = me.chart.options + var values = core_datasetController.prototype._resolveDatasetElementOptions.apply(me, arguments) + + values.spanGaps = valueOrDefault$7(config.spanGaps, options.spanGaps) + values.tension = valueOrDefault$7(config.lineTension, options.elements.line.tension) + + return values + }, + + updateBezierControlPoints: function() { + var me = this + var meta = me.getMeta() + var area = me.chart.chartArea + var points = meta.data || [] + var i, ilen, model, controlPoints + + // Only consider points that are drawn in case the spanGaps option is used + if (meta.dataset._model.spanGaps) { + points = points.filter(function(pt) { + return !pt._model.skip + }) + } + + function capControlPoint(pt, min, max) { + return Math.max(Math.min(pt, max), min) + } + + for (i = 0, ilen = points.length; i < ilen; ++i) { + model = points[i]._model + controlPoints = helpers$1.splineCurve( + helpers$1.previousItem(points, i, true)._model, + model, + helpers$1.nextItem(points, i, true)._model, + model.tension, + ) + + // Prevent the bezier going outside of the bounds of the graph + model.controlPointPreviousX = capControlPoint(controlPoints.previous.x, area.left, area.right) + model.controlPointPreviousY = capControlPoint(controlPoints.previous.y, area.top, area.bottom) + model.controlPointNextX = capControlPoint(controlPoints.next.x, area.left, area.right) + model.controlPointNextY = capControlPoint(controlPoints.next.y, area.top, area.bottom) + } + }, + + setHoverStyle: function(point) { + var model = point._model + var options = point._options + var getHoverColor = helpers$1.getHoverColor + + point.$previousStyle = { + backgroundColor: model.backgroundColor, + borderColor: model.borderColor, + borderWidth: model.borderWidth, + radius: model.radius, + } + + model.backgroundColor = valueOrDefault$7(options.hoverBackgroundColor, getHoverColor(options.backgroundColor)) + model.borderColor = valueOrDefault$7(options.hoverBorderColor, getHoverColor(options.borderColor)) + model.borderWidth = valueOrDefault$7(options.hoverBorderWidth, options.borderWidth) + model.radius = valueOrDefault$7(options.hoverRadius, options.radius) + }, + }) + + core_defaults._set('scatter', { + hover: { + mode: 'single', + }, + + scales: { + xAxes: [ + { + id: 'x-axis-1', // need an ID so datasets can reference the scale + type: 'linear', // scatter should not use a category axis + position: 'bottom', + }, + ], + yAxes: [ + { + id: 'y-axis-1', + type: 'linear', + position: 'left', + }, + ], + }, + + tooltips: { + callbacks: { + title: function() { + return '' // doesn't make sense for scatter since data are formatted as a point + }, + label: function(item) { + return '(' + item.xLabel + ', ' + item.yLabel + ')' + }, + }, + }, + }) + + core_defaults._set('global', { + datasets: { + scatter: { + showLine: false, + }, + }, + }) + + // Scatter charts use line controllers + var controller_scatter = controller_line + + // NOTE export a map in which the key represents the controller type, not + // the class, and so must be CamelCase in order to be correctly retrieved + // by the controller in core.controller.js (`controllers[meta.type]`). + + var controllers = { + bar: controller_bar, + bubble: controller_bubble, + doughnut: controller_doughnut, + horizontalBar: controller_horizontalBar, + line: controller_line, + polarArea: controller_polarArea, + pie: controller_pie, + radar: controller_radar, + scatter: controller_scatter, + } + + /** + * Helper function to get relative position for an event + * @param {Event|IEvent} event - The event to get the position for + * @param {Chart} chart - The chart + * @returns {object} the event position + */ + function getRelativePosition(e, chart) { + if (e.native) { + return { + x: e.x, + y: e.y, + } + } + + return helpers$1.getRelativePosition(e, chart) + } + + /** + * Helper function to traverse all of the visible elements in the chart + * @param {Chart} chart - the chart + * @param {function} handler - the callback to execute for each visible item + */ + function parseVisibleItems(chart, handler) { + var metasets = chart._getSortedVisibleDatasetMetas() + var metadata, i, j, ilen, jlen, element + + for (i = 0, ilen = metasets.length; i < ilen; ++i) { + metadata = metasets[i].data + for (j = 0, jlen = metadata.length; j < jlen; ++j) { + element = metadata[j] + if (!element._view.skip) { + handler(element) + } + } + } + } + + /** + * Helper function to get the items that intersect the event position + * @param {ChartElement[]} items - elements to filter + * @param {object} position - the point to be nearest to + * @return {ChartElement[]} the nearest items + */ + function getIntersectItems(chart, position) { + var elements = [] + + parseVisibleItems(chart, function(element) { + if (element.inRange(position.x, position.y)) { + elements.push(element) + } + }) + + return elements + } + + /** + * Helper function to get the items nearest to the event position considering all visible items in teh chart + * @param {Chart} chart - the chart to look at elements from + * @param {object} position - the point to be nearest to + * @param {boolean} intersect - if true, only consider items that intersect the position + * @param {function} distanceMetric - function to provide the distance between points + * @return {ChartElement[]} the nearest items + */ + function getNearestItems(chart, position, intersect, distanceMetric) { + var minDistance = Number.POSITIVE_INFINITY + var nearestItems = [] + + parseVisibleItems(chart, function(element) { + if (intersect && !element.inRange(position.x, position.y)) { + return + } + + var center = element.getCenterPoint() + var distance = distanceMetric(position, center) + if (distance < minDistance) { + nearestItems = [element] + minDistance = distance + } else if (distance === minDistance) { + // Can have multiple items at the same distance in which case we sort by size + nearestItems.push(element) + } + }) + + return nearestItems + } + + /** + * Get a distance metric function for two points based on the + * axis mode setting + * @param {string} axis - the axis mode. x|y|xy + */ + function getDistanceMetricForAxis(axis) { + var useX = axis.indexOf('x') !== -1 + var useY = axis.indexOf('y') !== -1 + + return function(pt1, pt2) { + var deltaX = useX ? Math.abs(pt1.x - pt2.x) : 0 + var deltaY = useY ? Math.abs(pt1.y - pt2.y) : 0 + return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) + } + } + + function indexMode(chart, e, options) { + var position = getRelativePosition(e, chart) + // Default axis for index mode is 'x' to match old behaviour + options.axis = options.axis || 'x' + var distanceMetric = getDistanceMetricForAxis(options.axis) + var items = options.intersect + ? getIntersectItems(chart, position) + : getNearestItems(chart, position, false, distanceMetric) + var elements = [] + + if (!items.length) { + return [] + } + + chart._getSortedVisibleDatasetMetas().forEach(function(meta) { + var element = meta.data[items[0]._index] + + // don't count items that are skipped (null data) + if (element && !element._view.skip) { + elements.push(element) + } + }) + + return elements + } + + /** + * @interface IInteractionOptions + */ + /** + * If true, only consider items that intersect the point + * @name IInterfaceOptions#boolean + * @type Boolean + */ + + /** + * Contains interaction related functions + * @namespace Chart.Interaction + */ + var core_interaction = { + // Helper function for different modes + modes: { + single: function(chart, e) { + var position = getRelativePosition(e, chart) + var elements = [] + + parseVisibleItems(chart, function(element) { + if (element.inRange(position.x, position.y)) { + elements.push(element) + return elements + } + }) + + return elements.slice(0, 1) + }, + + /** + * @function Chart.Interaction.modes.label + * @deprecated since version 2.4.0 + * @todo remove at version 3 + * @private + */ + label: indexMode, + + /** + * Returns items at the same index. If the options.intersect parameter is true, we only return items if we intersect something + * If the options.intersect mode is false, we find the nearest item and return the items at the same index as that item + * @function Chart.Interaction.modes.index + * @since v2.4.0 + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @param {IInteractionOptions} options - options to use during interaction + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + index: indexMode, + + /** + * Returns items in the same dataset. If the options.intersect parameter is true, we only return items if we intersect something + * If the options.intersect is false, we find the nearest item and return the items in that dataset + * @function Chart.Interaction.modes.dataset + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @param {IInteractionOptions} options - options to use during interaction + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + dataset: function(chart, e, options) { + var position = getRelativePosition(e, chart) + options.axis = options.axis || 'xy' + var distanceMetric = getDistanceMetricForAxis(options.axis) + var items = options.intersect + ? getIntersectItems(chart, position) + : getNearestItems(chart, position, false, distanceMetric) + + if (items.length > 0) { + items = chart.getDatasetMeta(items[0]._datasetIndex).data + } + + return items + }, + + /** + * @function Chart.Interaction.modes.x-axis + * @deprecated since version 2.4.0. Use index mode and intersect == true + * @todo remove at version 3 + * @private + */ + 'x-axis': function(chart, e) { + return indexMode(chart, e, { intersect: false }) + }, + + /** + * Point mode returns all elements that hit test based on the event position + * of the event + * @function Chart.Interaction.modes.intersect + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + point: function(chart, e) { + var position = getRelativePosition(e, chart) + return getIntersectItems(chart, position) + }, + + /** + * nearest mode returns the element closest to the point + * @function Chart.Interaction.modes.intersect + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @param {IInteractionOptions} options - options to use + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + nearest: function(chart, e, options) { + var position = getRelativePosition(e, chart) + options.axis = options.axis || 'xy' + var distanceMetric = getDistanceMetricForAxis(options.axis) + return getNearestItems(chart, position, options.intersect, distanceMetric) + }, + + /** + * x mode returns the elements that hit-test at the current x coordinate + * @function Chart.Interaction.modes.x + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @param {IInteractionOptions} options - options to use + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + x: function(chart, e, options) { + var position = getRelativePosition(e, chart) + var items = [] + var intersectsItem = false + + parseVisibleItems(chart, function(element) { + if (element.inXRange(position.x)) { + items.push(element) + } + + if (element.inRange(position.x, position.y)) { + intersectsItem = true + } + }) + + // If we want to trigger on an intersect and we don't have any items + // that intersect the position, return nothing + if (options.intersect && !intersectsItem) { + items = [] + } + return items + }, + + /** + * y mode returns the elements that hit-test at the current y coordinate + * @function Chart.Interaction.modes.y + * @param {Chart} chart - the chart we are returning items from + * @param {Event} e - the event we are find things at + * @param {IInteractionOptions} options - options to use + * @return {Chart.Element[]} Array of elements that are under the point. If none are found, an empty array is returned + */ + y: function(chart, e, options) { + var position = getRelativePosition(e, chart) + var items = [] + var intersectsItem = false + + parseVisibleItems(chart, function(element) { + if (element.inYRange(position.y)) { + items.push(element) + } + + if (element.inRange(position.x, position.y)) { + intersectsItem = true + } + }) + + // If we want to trigger on an intersect and we don't have any items + // that intersect the position, return nothing + if (options.intersect && !intersectsItem) { + items = [] + } + return items + }, + }, + } + + var extend = helpers$1.extend + + function filterByPosition(array, position) { + return helpers$1.where(array, function(v) { + return v.pos === position + }) + } + + function sortByWeight(array, reverse) { + return array.sort(function(a, b) { + var v0 = reverse ? b : a + var v1 = reverse ? a : b + return v0.weight === v1.weight ? v0.index - v1.index : v0.weight - v1.weight + }) + } + + function wrapBoxes(boxes) { + var layoutBoxes = [] + var i, ilen, box + + for (i = 0, ilen = (boxes || []).length; i < ilen; ++i) { + box = boxes[i] + layoutBoxes.push({ + index: i, + box: box, + pos: box.position, + horizontal: box.isHorizontal(), + weight: box.weight, + }) + } + return layoutBoxes + } + + function setLayoutDims(layouts, params) { + var i, ilen, layout + for (i = 0, ilen = layouts.length; i < ilen; ++i) { + layout = layouts[i] + // store width used instead of chartArea.w in fitBoxes + layout.width = layout.horizontal ? layout.box.fullWidth && params.availableWidth : params.vBoxMaxWidth + // store height used instead of chartArea.h in fitBoxes + layout.height = layout.horizontal && params.hBoxMaxHeight + } + } + + function buildLayoutBoxes(boxes) { + var layoutBoxes = wrapBoxes(boxes) + var left = sortByWeight(filterByPosition(layoutBoxes, 'left'), true) + var right = sortByWeight(filterByPosition(layoutBoxes, 'right')) + var top = sortByWeight(filterByPosition(layoutBoxes, 'top'), true) + var bottom = sortByWeight(filterByPosition(layoutBoxes, 'bottom')) + + return { + leftAndTop: left.concat(top), + rightAndBottom: right.concat(bottom), + chartArea: filterByPosition(layoutBoxes, 'chartArea'), + vertical: left.concat(right), + horizontal: top.concat(bottom), + } + } + + function getCombinedMax(maxPadding, chartArea, a, b) { + return Math.max(maxPadding[a], chartArea[a]) + Math.max(maxPadding[b], chartArea[b]) + } + + function updateDims(chartArea, params, layout) { + var box = layout.box + var maxPadding = chartArea.maxPadding + var newWidth, newHeight + + if (layout.size) { + // this layout was already counted for, lets first reduce old size + chartArea[layout.pos] -= layout.size + } + layout.size = layout.horizontal ? box.height : box.width + chartArea[layout.pos] += layout.size + + if (box.getPadding) { + var boxPadding = box.getPadding() + maxPadding.top = Math.max(maxPadding.top, boxPadding.top) + maxPadding.left = Math.max(maxPadding.left, boxPadding.left) + maxPadding.bottom = Math.max(maxPadding.bottom, boxPadding.bottom) + maxPadding.right = Math.max(maxPadding.right, boxPadding.right) + } + + newWidth = params.outerWidth - getCombinedMax(maxPadding, chartArea, 'left', 'right') + newHeight = params.outerHeight - getCombinedMax(maxPadding, chartArea, 'top', 'bottom') + + if (newWidth !== chartArea.w || newHeight !== chartArea.h) { + chartArea.w = newWidth + chartArea.h = newHeight + + // return true if chart area changed in layout's direction + return layout.horizontal ? newWidth !== chartArea.w : newHeight !== chartArea.h + } + } + + function handleMaxPadding(chartArea) { + var maxPadding = chartArea.maxPadding + + function updatePos(pos) { + var change = Math.max(maxPadding[pos] - chartArea[pos], 0) + chartArea[pos] += change + return change + } + chartArea.y += updatePos('top') + chartArea.x += updatePos('left') + updatePos('right') + updatePos('bottom') + } + + function getMargins(horizontal, chartArea) { + var maxPadding = chartArea.maxPadding + + function marginForPositions(positions) { + var margin = { left: 0, top: 0, right: 0, bottom: 0 } + positions.forEach(function(pos) { + margin[pos] = Math.max(chartArea[pos], maxPadding[pos]) + }) + return margin + } + + return horizontal ? marginForPositions(['left', 'right']) : marginForPositions(['top', 'bottom']) + } + + function fitBoxes(boxes, chartArea, params) { + var refitBoxes = [] + var i, ilen, layout, box, refit, changed + + for (i = 0, ilen = boxes.length; i < ilen; ++i) { + layout = boxes[i] + box = layout.box + + box.update(layout.width || chartArea.w, layout.height || chartArea.h, getMargins(layout.horizontal, chartArea)) + if (updateDims(chartArea, params, layout)) { + changed = true + if (refitBoxes.length) { + // Dimensions changed and there were non full width boxes before this + // -> we have to refit those + refit = true + } + } + if (!box.fullWidth) { + // fullWidth boxes don't need to be re-fitted in any case + refitBoxes.push(layout) + } + } + + return refit ? fitBoxes(refitBoxes, chartArea, params) || changed : changed + } + + function placeBoxes(boxes, chartArea, params) { + var userPadding = params.padding + var x = chartArea.x + var y = chartArea.y + var i, ilen, layout, box + + for (i = 0, ilen = boxes.length; i < ilen; ++i) { + layout = boxes[i] + box = layout.box + if (layout.horizontal) { + box.left = box.fullWidth ? userPadding.left : chartArea.left + box.right = box.fullWidth ? params.outerWidth - userPadding.right : chartArea.left + chartArea.w + box.top = y + box.bottom = y + box.height + box.width = box.right - box.left + y = box.bottom + } else { + box.left = x + box.right = x + box.width + box.top = chartArea.top + box.bottom = chartArea.top + chartArea.h + box.height = box.bottom - box.top + x = box.right + } + } + + chartArea.x = x + chartArea.y = y + } + + core_defaults._set('global', { + layout: { + padding: { + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + }, + }) + + /** + * @interface ILayoutItem + * @prop {string} position - The position of the item in the chart layout. Possible values are + * 'left', 'top', 'right', 'bottom', and 'chartArea' + * @prop {number} weight - The weight used to sort the item. Higher weights are further away from the chart area + * @prop {boolean} fullWidth - if true, and the item is horizontal, then push vertical boxes down + * @prop {function} isHorizontal - returns true if the layout item is horizontal (ie. top or bottom) + * @prop {function} update - Takes two parameters: width and height. Returns size of item + * @prop {function} getPadding - Returns an object with padding on the edges + * @prop {number} width - Width of item. Must be valid after update() + * @prop {number} height - Height of item. Must be valid after update() + * @prop {number} left - Left edge of the item. Set by layout system and cannot be used in update + * @prop {number} top - Top edge of the item. Set by layout system and cannot be used in update + * @prop {number} right - Right edge of the item. Set by layout system and cannot be used in update + * @prop {number} bottom - Bottom edge of the item. Set by layout system and cannot be used in update + */ + + // The layout service is very self explanatory. It's responsible for the layout within a chart. + // Scales, Legends and Plugins all rely on the layout service and can easily register to be placed anywhere they need + // It is this service's responsibility of carrying out that layout. + var core_layouts = { + defaults: {}, + + /** + * Register a box to a chart. + * A box is simply a reference to an object that requires layout. eg. Scales, Legend, Title. + * @param {Chart} chart - the chart to use + * @param {ILayoutItem} item - the item to add to be layed out + */ + addBox: function(chart, item) { + if (!chart.boxes) { + chart.boxes = [] + } + + // initialize item with default values + item.fullWidth = item.fullWidth || false + item.position = item.position || 'top' + item.weight = item.weight || 0 + item._layers = + item._layers || + function() { + return [ + { + z: 0, + draw: function() { + item.draw.apply(item, arguments) + }, + }, + ] + } + + chart.boxes.push(item) + }, + + /** + * Remove a layoutItem from a chart + * @param {Chart} chart - the chart to remove the box from + * @param {ILayoutItem} layoutItem - the item to remove from the layout + */ + removeBox: function(chart, layoutItem) { + var index = chart.boxes ? chart.boxes.indexOf(layoutItem) : -1 + if (index !== -1) { + chart.boxes.splice(index, 1) + } + }, + + /** + * Sets (or updates) options on the given `item`. + * @param {Chart} chart - the chart in which the item lives (or will be added to) + * @param {ILayoutItem} item - the item to configure with the given options + * @param {object} options - the new item options. + */ + configure: function(chart, item, options) { + var props = ['fullWidth', 'position', 'weight'] + var ilen = props.length + var i = 0 + var prop + + for (; i < ilen; ++i) { + prop = props[i] + if (options.hasOwnProperty(prop)) { + item[prop] = options[prop] + } + } + }, + + /** + * Fits boxes of the given chart into the given size by having each box measure itself + * then running a fitting algorithm + * @param {Chart} chart - the chart + * @param {number} width - the width to fit into + * @param {number} height - the height to fit into + */ + update: function(chart, width, height) { + if (!chart) { + return + } + + var layoutOptions = chart.options.layout || {} + var padding = helpers$1.options.toPadding(layoutOptions.padding) + + var availableWidth = width - padding.width + var availableHeight = height - padding.height + var boxes = buildLayoutBoxes(chart.boxes) + var verticalBoxes = boxes.vertical + var horizontalBoxes = boxes.horizontal + + // Essentially we now have any number of boxes on each of the 4 sides. + // Our canvas looks like the following. + // The areas L1 and L2 are the left axes. R1 is the right axis, T1 is the top axis and + // B1 is the bottom axis + // There are also 4 quadrant-like locations (left to right instead of clockwise) reserved for chart overlays + // These locations are single-box locations only, when trying to register a chartArea location that is already taken, + // an error will be thrown. + // + // |----------------------------------------------------| + // | T1 (Full Width) | + // |----------------------------------------------------| + // | | | T2 | | + // | |----|-------------------------------------|----| + // | | | C1 | | C2 | | + // | | |----| |----| | + // | | | | | + // | L1 | L2 | ChartArea (C0) | R1 | + // | | | | | + // | | |----| |----| | + // | | | C3 | | C4 | | + // | |----|-------------------------------------|----| + // | | | B1 | | + // |----------------------------------------------------| + // | B2 (Full Width) | + // |----------------------------------------------------| + // + + var params = Object.freeze({ + outerWidth: width, + outerHeight: height, + padding: padding, + availableWidth: availableWidth, + vBoxMaxWidth: availableWidth / 2 / verticalBoxes.length, + hBoxMaxHeight: availableHeight / 2, + }) + var chartArea = extend( + { + maxPadding: extend({}, padding), + w: availableWidth, + h: availableHeight, + x: padding.left, + y: padding.top, + }, + padding, + ) + + setLayoutDims(verticalBoxes.concat(horizontalBoxes), params) + + // First fit vertical boxes + fitBoxes(verticalBoxes, chartArea, params) + + // Then fit horizontal boxes + if (fitBoxes(horizontalBoxes, chartArea, params)) { + // if the area changed, re-fit vertical boxes + fitBoxes(verticalBoxes, chartArea, params) + } + + handleMaxPadding(chartArea) + + // Finally place the boxes to correct coordinates + placeBoxes(boxes.leftAndTop, chartArea, params) + + // Move to opposite side of chart + chartArea.x += chartArea.w + chartArea.y += chartArea.h + + placeBoxes(boxes.rightAndBottom, chartArea, params) + + chart.chartArea = { + left: chartArea.left, + top: chartArea.top, + right: chartArea.left + chartArea.w, + bottom: chartArea.top + chartArea.h, + } + + // Finally update boxes in chartArea (radial scale for example) + helpers$1.each(boxes.chartArea, function(layout) { + var box = layout.box + extend(box, chart.chartArea) + box.update(chartArea.w, chartArea.h) + }) + }, + } + + /** + * Platform fallback implementation (minimal). + * @see https://github.com/chartjs/Chart.js/pull/4591#issuecomment-319575939 + */ + + var platform_basic = { + acquireContext: function(item) { + if (item && item.canvas) { + // Support for any object associated to a canvas (including a context2d) + item = item.canvas + } + + return (item && item.getContext('2d')) || null + }, + } + + var platform_dom = + '/*\n * DOM element rendering detection\n * https://davidwalsh.name/detect-node-insertion\n */\n@keyframes chartjs-render-animation {\n\tfrom { opacity: 0.99; }\n\tto { opacity: 1; }\n}\n\n.chartjs-render-monitor {\n\tanimation: chartjs-render-animation 0.001s;\n}\n\n/*\n * DOM element resizing detection\n * https://github.com/marcj/css-element-queries\n */\n.chartjs-size-monitor,\n.chartjs-size-monitor-expand,\n.chartjs-size-monitor-shrink {\n\tposition: absolute;\n\tdirection: ltr;\n\tleft: 0;\n\ttop: 0;\n\tright: 0;\n\tbottom: 0;\n\toverflow: hidden;\n\tpointer-events: none;\n\tvisibility: hidden;\n\tz-index: -1;\n}\n\n.chartjs-size-monitor-expand > div {\n\tposition: absolute;\n\twidth: 1000000px;\n\theight: 1000000px;\n\tleft: 0;\n\ttop: 0;\n}\n\n.chartjs-size-monitor-shrink > div {\n\tposition: absolute;\n\twidth: 200%;\n\theight: 200%;\n\tleft: 0;\n\ttop: 0;\n}\n' + + var platform_dom$1 = /*#__PURE__*/ Object.freeze({ + __proto__: null, + default: platform_dom, + }) + + var stylesheet = getCjsExportFromNamespace(platform_dom$1) + + var EXPANDO_KEY = '$chartjs' + var CSS_PREFIX = 'chartjs-' + var CSS_SIZE_MONITOR = CSS_PREFIX + 'size-monitor' + var CSS_RENDER_MONITOR = CSS_PREFIX + 'render-monitor' + var CSS_RENDER_ANIMATION = CSS_PREFIX + 'render-animation' + var ANIMATION_START_EVENTS = ['animationstart', 'webkitAnimationStart'] + + /** + * DOM event types -> Chart.js event types. + * Note: only events with different types are mapped. + * @see https://developer.mozilla.org/en-US/docs/Web/Events + */ + var EVENT_TYPES = { + touchstart: 'mousedown', + touchmove: 'mousemove', + touchend: 'mouseup', + pointerenter: 'mouseenter', + pointerdown: 'mousedown', + pointermove: 'mousemove', + pointerup: 'mouseup', + pointerleave: 'mouseout', + pointerout: 'mouseout', + } + + /** + * The "used" size is the final value of a dimension property after all calculations have + * been performed. This method uses the computed style of `element` but returns undefined + * if the computed style is not expressed in pixels. That can happen in some cases where + * `element` has a size relative to its parent and this last one is not yet displayed, + * for example because of `display: none` on a parent node. + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value + * @returns {number} Size in pixels or undefined if unknown. + */ + function readUsedSize(element, property) { + var value = helpers$1.getStyle(element, property) + var matches = value && value.match(/^(\d+)(\.\d+)?px$/) + return matches ? Number(matches[1]) : undefined + } + + /** + * Initializes the canvas style and render size without modifying the canvas display size, + * since responsiveness is handled by the controller.resize() method. The config is used + * to determine the aspect ratio to apply in case no explicit height has been specified. + */ + function initCanvas(canvas, config) { + var style = canvas.style + + // NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it + // returns null or '' if no explicit value has been set to the canvas attribute. + var renderHeight = canvas.getAttribute('height') + var renderWidth = canvas.getAttribute('width') + + // Chart.js modifies some canvas values that we want to restore on destroy + canvas[EXPANDO_KEY] = { + initial: { + height: renderHeight, + width: renderWidth, + style: { + display: style.display, + height: style.height, + width: style.width, + }, + }, + } + + // Force canvas to display as block to avoid extra space caused by inline + // elements, which would interfere with the responsive resize process. + // https://github.com/chartjs/Chart.js/issues/2538 + style.display = style.display || 'block' + + if (renderWidth === null || renderWidth === '') { + var displayWidth = readUsedSize(canvas, 'width') + if (displayWidth !== undefined) { + canvas.width = displayWidth + } + } + + if (renderHeight === null || renderHeight === '') { + if (canvas.style.height === '') { + // If no explicit render height and style height, let's apply the aspect ratio, + // which one can be specified by the user but also by charts as default option + // (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2. + canvas.height = canvas.width / (config.options.aspectRatio || 2) + } else { + var displayHeight = readUsedSize(canvas, 'height') + if (displayWidth !== undefined) { + canvas.height = displayHeight + } + } + } + + return canvas + } + + /** + * Detects support for options object argument in addEventListener. + * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support + * @private + */ + var supportsEventListenerOptions = (function() { + var supports = false + try { + var options = Object.defineProperty({}, 'passive', { + // eslint-disable-next-line getter-return + get: function() { + supports = true + }, + }) + window.addEventListener('e', null, options) + } catch (e) { + // continue regardless of error + } + return supports + })() + + // Default passive to true as expected by Chrome for 'touchstart' and 'touchend' events. + // https://github.com/chartjs/Chart.js/issues/4287 + var eventListenerOptions = supportsEventListenerOptions ? { passive: true } : false + + function addListener(node, type, listener) { + node.addEventListener(type, listener, eventListenerOptions) + } + + function removeListener(node, type, listener) { + node.removeEventListener(type, listener, eventListenerOptions) + } + + function createEvent(type, chart, x, y, nativeEvent) { + return { + type: type, + chart: chart, + native: nativeEvent || null, + x: x !== undefined ? x : null, + y: y !== undefined ? y : null, + } + } + + function fromNativeEvent(event, chart) { + var type = EVENT_TYPES[event.type] || event.type + var pos = helpers$1.getRelativePosition(event, chart) + return createEvent(type, chart, pos.x, pos.y, event) + } + + function throttled(fn, thisArg) { + var ticking = false + var args = [] + + return function() { + args = Array.prototype.slice.call(arguments) + thisArg = thisArg || this + + if (!ticking) { + ticking = true + helpers$1.requestAnimFrame.call(window, function() { + ticking = false + fn.apply(thisArg, args) + }) + } + } + } + + function createDiv(cls) { + var el = document.createElement('div') + el.className = cls || '' + return el + } + + // Implementation based on https://github.com/marcj/css-element-queries + function createResizer(handler) { + var maxSize = 1000000 + + // NOTE(SB) Don't use innerHTML because it could be considered unsafe. + // https://github.com/chartjs/Chart.js/issues/5902 + var resizer = createDiv(CSS_SIZE_MONITOR) + var expand = createDiv(CSS_SIZE_MONITOR + '-expand') + var shrink = createDiv(CSS_SIZE_MONITOR + '-shrink') + + expand.appendChild(createDiv()) + shrink.appendChild(createDiv()) + + resizer.appendChild(expand) + resizer.appendChild(shrink) + resizer._reset = function() { + expand.scrollLeft = maxSize + expand.scrollTop = maxSize + shrink.scrollLeft = maxSize + shrink.scrollTop = maxSize + } + + var onScroll = function() { + resizer._reset() + handler() + } + + addListener(expand, 'scroll', onScroll.bind(expand, 'expand')) + addListener(shrink, 'scroll', onScroll.bind(shrink, 'shrink')) + + return resizer + } + + // https://davidwalsh.name/detect-node-insertion + function watchForRender(node, handler) { + var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}) + var proxy = (expando.renderProxy = function(e) { + if (e.animationName === CSS_RENDER_ANIMATION) { + handler() + } + }) + + helpers$1.each(ANIMATION_START_EVENTS, function(type) { + addListener(node, type, proxy) + }) + + // #4737: Chrome might skip the CSS animation when the CSS_RENDER_MONITOR class + // is removed then added back immediately (same animation frame?). Accessing the + // `offsetParent` property will force a reflow and re-evaluate the CSS animation. + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a#box-metrics + // https://github.com/chartjs/Chart.js/issues/4737 + expando.reflow = !!node.offsetParent + + node.classList.add(CSS_RENDER_MONITOR) + } + + function unwatchForRender(node) { + var expando = node[EXPANDO_KEY] || {} + var proxy = expando.renderProxy + + if (proxy) { + helpers$1.each(ANIMATION_START_EVENTS, function(type) { + removeListener(node, type, proxy) + }) + + delete expando.renderProxy + } + + node.classList.remove(CSS_RENDER_MONITOR) + } + + function addResizeListener(node, listener, chart) { + var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}) + + // Let's keep track of this added resizer and thus avoid DOM query when removing it. + var resizer = (expando.resizer = createResizer( + throttled(function() { + if (expando.resizer) { + var container = chart.options.maintainAspectRatio && node.parentNode + var w = container ? container.clientWidth : 0 + listener(createEvent('resize', chart)) + if (container && container.clientWidth < w && chart.canvas) { + // If the container size shrank during chart resize, let's assume + // scrollbar appeared. So we resize again with the scrollbar visible - + // effectively making chart smaller and the scrollbar hidden again. + // Because we are inside `throttled`, and currently `ticking`, scroll + // events are ignored during this whole 2 resize process. + // If we assumed wrong and something else happened, we are resizing + // twice in a frame (potential performance issue) + listener(createEvent('resize', chart)) + } + } + }), + )) + + // The resizer needs to be attached to the node parent, so we first need to be + // sure that `node` is attached to the DOM before injecting the resizer element. + watchForRender(node, function() { + if (expando.resizer) { + var container = node.parentNode + if (container && container !== resizer.parentNode) { + container.insertBefore(resizer, container.firstChild) + } + + // The container size might have changed, let's reset the resizer state. + resizer._reset() + } + }) + } + + function removeResizeListener(node) { + var expando = node[EXPANDO_KEY] || {} + var resizer = expando.resizer + + delete expando.resizer + unwatchForRender(node) + + if (resizer && resizer.parentNode) { + resizer.parentNode.removeChild(resizer) + } + } + + /** + * Injects CSS styles inline if the styles are not already present. + * @param {HTMLDocument|ShadowRoot} rootNode - the node to contain the