diff --git a/.eslintrc.js b/.eslintrc.js index 2ec7b8ef..408ef604 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -73,6 +73,7 @@ module.exports = { 'cypress/no-unnecessary-waiting': 'error', 'cypress/assertion-before-screenshot': 'warn', 'default-case': 'error', + indent: 'off', }, overrides: [ { 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/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/autocomplete.tsx b/common/components/src/autocomplete.tsx new file mode 100644 index 00000000..2cd69304 --- /dev/null +++ b/common/components/src/autocomplete.tsx @@ -0,0 +1,45 @@ +import { Shade, createComponent } from '@furystack/shades' +import { v4 } from 'uuid' +import { Input, InputProps } from './input' + +export const Autocomplete = Shade< + { inputProps?: InputProps; suggestions: string[]; strict?: boolean; onchange: (value: string) => void }, + { dataListId: string } +>({ + getInitialState: () => ({ + dataListId: v4(), + }), + constructed: ({ getState, element }) => { + const { dataListId } = getState() + const input = element.querySelector('input') + if (input) { + input.setAttribute('list', dataListId) + } + }, + shadowDomName: 'shade-autocomplete', + render: ({ props, getState }) => { + const { dataListId } = getState() + return ( +
+ { + const { value } = ev.target as any + if (props.strict) { + if (!props.suggestions.includes(value)) { + ;(ev.target as HTMLInputElement).setCustomValidity('Please select a valid entry!') + return + } + } + props.onchange(value) + }} + /> + + {props.suggestions.map((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/components/src/index.ts b/common/components/src/index.ts index 3df3a8e1..7e3b26d8 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 './autocomplete' 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/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/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/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..f11fb824 100644 --- a/common/config/src/databases.ts +++ b/common/config/src/databases.ts @@ -5,7 +5,7 @@ export const databases = { usersCollection: 'users', sessionStore: { host: process.env.SESSION_STORE_HOST || process.env.REDIS_HOST || 'localhost', - port: process.env.SESSION_STORE_PORT || process.env.REDIS_PORT || '63790', + port: process.env.SESSION_STORE_PORT || process.env.REDIS_PORT || '6379', }, }, logging: { @@ -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..d0ec3c73 100644 --- a/common/config/src/sites.ts +++ b/common/config/src/sites.ts @@ -2,11 +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_EXTERNAL_URL || 'http://localhost:9092/api', }, }, frontends: { 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/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/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..f2ee0fbc 100644 --- a/common/frontend-utils/src/service-list.ts +++ b/common/frontend-utils/src/service-list.ts @@ -1,4 +1,4 @@ -import { roles } from 'common-models/src' +import { roles } from 'common-models' export interface ServiceDescription { name: string @@ -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/apis/xpense-api.ts b/common/models/src/apis/xpense-api.ts new file mode 100644 index 00000000..fd321bcf --- /dev/null +++ b/common/models/src/apis/xpense-api.ts @@ -0,0 +1,39 @@ +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'] + current: Account['current'] + }> + }> + '/replenishments/:replenishmentId': RequestAction<{ urlParams: { replenishmentId: string }; result: Replenishment }> + '/shops/:shopId': RequestAction<{ urlParams: { shopId: string }; result: Shop }> + '/shopping/:shoppingId': RequestAction<{ urlParams: { shoppingId: string }; result: Shopping }> + '/:type/:owner/:accountName': RequestAction<{ + result: Account + urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } + }> + } + POST: { + '/accounts': RequestAction<{ result: Account; body: { name: string; description: string; icon: 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 } + urlParams: { type: 'user' | 'organization'; owner: string; accountName: string } + result: Replenishment + }> + '/:type/:owner/:accountName/shop': RequestAction<{ + 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/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/models/src/organization.ts b/common/models/src/organization.ts index a81fb1e9..8e6a7ee0 100644 --- a/common/models/src/organization.ts +++ b/common/models/src/organization.ts @@ -1,6 +1,7 @@ export class Organization { _id!: string name!: string + icon!: string description!: string /** * The owner user name diff --git a/common/models/src/xpense/account.ts b/common/models/src/xpense/account.ts new file mode 100644 index 00000000..b70affcd --- /dev/null +++ b/common/models/src/xpense/account.ts @@ -0,0 +1,18 @@ +export class Account { + _id!: string + ownerType!: 'user' | 'organization' + ownerName!: string + name!: string + description!: string + icon!: string + history!: Array<{ + balance: number + date: string + change: number + changePerCategory: Array<{ categoryName: string; amount: number }> + relatedEntry: { type: 'replenishment'; replenishmentId: string } | { type: 'shopping'; 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..893e9bcc --- /dev/null +++ b/common/models/src/xpense/shopping.ts @@ -0,0 +1,9 @@ +export class Shopping { + _id!: string + accountId!: string + creationDate!: string + entries!: Array<{ itemName: string; amount: number; unitPrice: number }> + sumAmount!: number + shopName!: string + createdBy!: string +} diff --git a/common/service-utils/src/authorized-data-set.ts b/common/service-utils/src/authorized-data-set.ts new file mode 100644 index 00000000..17f1fbfc --- /dev/null +++ b/common/service-utils/src/authorized-data-set.ts @@ -0,0 +1,19 @@ +import { Injector } from '@furystack/inject' +import { HttpUserContext } from '@furystack/rest-service' +import { DataSetSettings } from '@furystack/repository' + +export const authorizedOnly = async (options: { injector: Injector }) => { + const authorized = await options.injector.getInstance(HttpUserContext).isAuthenticated() + return { + isAllowed: authorized, + message: 'You are not authorized :(', + } +} + +export const authorizedDataSet: Partial> = { + authorizeAdd: authorizedOnly, + authorizeGet: authorizedOnly, + authorizeRemove: authorizedOnly, + authorizeUpdate: authorizedOnly, + authroizeRemoveEntity: authorizedOnly, +} diff --git a/common/service-utils/src/get-orgs-for-user.ts b/common/service-utils/src/get-orgs-for-user.ts new file mode 100644 index 00000000..6592007f --- /dev/null +++ b/common/service-utils/src/get-orgs-for-user.ts @@ -0,0 +1,15 @@ +import { Injector } from '@furystack/inject' +import { MongodbStore } from '@furystack/mongodb-store' +import { Organization } from 'common-models' +import { StoreManager, User } from '@furystack/core' + +export const getOrgsForCurrentUser = async (injector: Injector, user: User) => { + const store: MongodbStore = injector.getInstance(StoreManager).getStoreFor(Organization) + const collection = await store.getCollection() + + return await collection + .find({ + $or: [{ ownerName: { $eq: user.username } }, { adminNames: user.username }, { memberNames: user.username }], + }) + .toArray() +} diff --git a/common/service-utils/src/index.ts b/common/service-utils/src/index.ts index ace2982c..76e9c6a9 100644 --- a/common/service-utils/src/index.ts +++ b/common/service-utils/src/index.ts @@ -1,4 +1,6 @@ import './use-common-http-auth' import './use-db-logger' +export * from './authorized-data-set' export * from './create-indexes' export * from './shutdown' +export * from './get-orgs-for-user' diff --git a/common/service-utils/src/shutdown.ts b/common/service-utils/src/shutdown.ts index 2852c54d..607eb8c4 100644 --- a/common/service-utils/src/shutdown.ts +++ b/common/service-utils/src/shutdown.ts @@ -5,11 +5,15 @@ import { DbLogger } from './use-db-logger' export const attachShutdownHandler = (i: Injector) => { const logger = i.logger.withScope('shutdown-handler') - const onExit = async ({ code, reason }: { code: number; reason: string }) => { + const onExit = async ({ code, reason }: { code: number; reason: string; error?: any }) => { try { if (code) { await logger.warning({ message: `Something bad happened, starting shutdown with code '${code}' due '${reason}'`, + data: { + code, + reason, + }, }) } else { await logger.warning({ message: `Shutting down gracefully due '${reason}'` }) @@ -35,7 +39,9 @@ export const attachShutdownHandler = (i: Injector) => { process.once('SIGUSR2', () => onExit({ code: 0, reason: 'SIGUSR2' })) // catches uncaught exceptions - process.once('uncaughtException', () => onExit({ code: 1, reason: 'uncaughtException' })) + process.once('uncaughtException', (error) => { + onExit({ code: 1, reason: 'uncaughtException', error }) + }) - process.once('unhandledRejection', () => onExit({ code: 1, reason: 'unhandledRejection' })) + process.once('unhandledRejection', (error) => onExit({ code: 1, reason: 'unhandledRejection', error })) } diff --git a/common/service-utils/src/use-common-http-auth.ts b/common/service-utils/src/use-common-http-auth.ts index 32433d3a..d20ec7e1 100644 --- a/common/service-utils/src/use-common-http-auth.ts +++ b/common/service-utils/src/use-common-http-auth.ts @@ -1,11 +1,12 @@ import '@furystack/redis-store' -import '@furystack/rest-service' +import { HttpUserContext } from '@furystack/rest-service' import '@furystack/mongodb-store' import { Injector } from '@furystack/inject/dist/injector' import { createClient } from 'redis' import { databases } from 'common-config' -import { Session, User } from 'common-models' +import { Session, User, Organization } from 'common-models' import { verifyAndCreateIndexes } from './create-indexes' +import { authorizedDataSet } from './authorized-data-set' declare module '@furystack/inject/dist/injector' { interface Injector { @@ -13,7 +14,7 @@ declare module '@furystack/inject/dist/injector' { } } -Injector.prototype.useCommonHttpAuth = function () { +Injector.prototype.useCommonHttpAuth = function() { this.setupStores((sm) => sm .useMongoDb({ @@ -21,7 +22,14 @@ 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, + }) + .useMongoDb({ + model: Organization, + url: databases['common-auth'].mongoUrl, + db: databases['common-auth'].dbName, + collection: 'organizations', + options: databases.standardOptions, }) .useRedis( Session, @@ -39,6 +47,31 @@ Injector.prototype.useCommonHttpAuth = function () { getSessionStore: (sm) => sm.getStoreFor(Session), }) + this.setupRepository((repo) => + repo + .createDataSet(Organization, { + name: 'organizations', + authorizeUpdateEntity: async ({ injector: i, entity }) => { + const currentUser = await i.getInstance(HttpUserContext).getCurrentUser() + if (entity.ownerName === currentUser.username || entity.adminNames.includes(currentUser.username)) { + return { isAllowed: true } + } + return { isAllowed: false, message: 'Only the owner or admins can modify an organization' } + }, + }) + .createDataSet(User, { + ...authorizedDataSet, + authorizeAdd: async (authorize) => { + const success = await authorize.injector.getInstance(HttpUserContext).isAuthorized('user-admin') + return { + isAllowed: success ? true : false, + message: success ? '' : "Role 'user-admin' required.", + } + }, + name: 'users', + }), + ) + verifyAndCreateIndexes({ injector: this, model: User, diff --git a/common/service-utils/src/use-db-logger.ts b/common/service-utils/src/use-db-logger.ts index 5d6040e8..0a6fac28 100644 --- a/common/service-utils/src/use-db-logger.ts +++ b/common/service-utils/src/use-db-logger.ts @@ -54,25 +54,21 @@ Injector.prototype.useDbLogger = function(settings) { collection: databases.logging.logCollection, url: databases.logging.mongoUrl, db: databases.logging.dbName, - options: { - useUnifiedTopology: true, - }, + options: databases.standardOptions, }), ) this.setupRepository((repo) => repo.createDataSet(LogEntry, { - authorizeUpdate: async () => ({ isAllowed: false, message: 'The Log is read only, updates are permitted!' }), - authorizeGet: async ({ injector }) => { - const isAllowed = await injector.getInstance(HttpUserContext).isAuthorized('sys-logs') - if (!isAllowed) { - return { - isAllowed, - message: "You need 'sys-logs' role to see the entries", - } - } + name: 'logEntries', + authorizeAdd: async () => ({ isAllowed: false, message: 'The DataSet is read only' }), + authorizeRemove: async () => ({ isAllowed: false, message: 'The DataSet is read only' }), + authorizeUpdate: async () => ({ isAllowed: false, message: 'The DataSet is read only' }), + authorizeGet: async (options) => { + const result = await options.injector.getInstance(HttpUserContext).isAuthorized('sys-logs') return { - isAllowed, + isAllowed: result, + message: result ? '' : "Role 'sys-logs' required", } }, }), diff --git a/docker-compose.yml b/docker-compose.yml index 3bbe3a3e..6b226fcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: redis: image: 'redis' ports: - - '63790:6379' + - '6379:6379' mongo: image: 'mongo' ports: diff --git a/frontends/wrap-r/package.json b/frontends/wrap-r/package.json index 7fcd37b7..cf13d4a2 100644 --- a/frontends/wrap-r/package.json +++ b/frontends/wrap-r/package.json @@ -34,6 +34,8 @@ "@furystack/rest": "^1.1.1", "@furystack/rest-client-fetch": "^1.1.2", "@furystack/shades": "^3.0.0", - "mini-css-extract-plugin": "^0.9.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/components/body.tsx b/frontends/wrap-r/src/components/body.tsx index e60165e5..69a1fd88 100644 --- a/frontends/wrap-r/src/components/body.tsx +++ b/frontends/wrap-r/src/components/body.tsx @@ -85,14 +85,63 @@ 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/add-account', + component: () => ( + { + const { AddXpenseAccountPage } = await import( + /* webpackChunkName: "xpense-add-account" */ '../pages/xpense/add-account' + ) + return + }} + loader={} + /> + ), + }, + { + url: '/xpense/:type?/:owner?/:accountName?', + routingOptions: { + end: false, + }, + component: ({ match }) => ( + { + const { XpensePage } = await import(/* webpackChunkName: "xpense" */ '../pages/xpense') + return ( + ) - return }} loader={} /> diff --git a/frontends/wrap-r/src/components/header.tsx b/frontends/wrap-r/src/components/header.tsx index 34ed7279..bd9dbfa1 100644 --- a/frontends/wrap-r/src/components/header.tsx +++ b/frontends/wrap-r/src/components/header.tsx @@ -20,12 +20,13 @@ export const Header = Shade({ notFound={() =>
} routes={serviceList.map((s) => ({ url: s.url, + routingOptions: { end: false }, onVisit: animations.showSlide, onLeave: animations.hideSlide, component: () => ( - + - {s.icon} {s.name} - + ), }))} /> diff --git a/frontends/wrap-r/src/pages/offline.tsx b/frontends/wrap-r/src/pages/offline.tsx index 4005e2f2..91b9d35c 100644 --- a/frontends/wrap-r/src/pages/offline.tsx +++ b/frontends/wrap-r/src/pages/offline.tsx @@ -14,10 +14,6 @@ export const Offline = Shade({ justifyContent: 'center', padding: '0 100px', }}> -
>({ + getInitialState: () => ({ adminNames: [], memberNames: [], description: '', name: '', ownerName: '', icon: '💳' }), + 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: value }, true)} /> + updateState({ icon: value }, true)} /> + updateState({ description: value }, true)} + /> + + +
+
+ ) + }, +}) 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/frontends/wrap-r/src/pages/welcome-page.tsx b/frontends/wrap-r/src/pages/welcome-page.tsx index 9c5248ee..c971c69f 100644 --- a/frontends/wrap-r/src/pages/welcome-page.tsx +++ b/frontends/wrap-r/src/pages/welcome-page.tsx @@ -1,9 +1,9 @@ import { Shade, createComponent, RouteLink } from '@furystack/shades' import { styles } from 'common-components' -import { SessionService, ServiceDescription, serviceList, promisifyAnimation } from 'common-frontend-utils' +import { SessionService, serviceList, promisifyAnimation } from 'common-frontend-utils' import { User } from 'common-models' -export const Widget = Shade({ +export const Widget = Shade<{ url: string; icon: string; name: string; description: string; index: number }>({ shadowDomName: 'shade-welcome-screen-widget', render: ({ props, element }) => { setTimeout(() => { @@ -16,6 +16,7 @@ export const Widget = Shade({ return ( promisifyAnimation( ev.target as any, @@ -101,7 +102,13 @@ export const WelcomePage = Shade<{}, { currentUser: User | null }>({ {serviceList .filter((service) => service.requiredRoles.every((role) => getState().currentUser?.roles.includes(role))) .map((service, index) => ( - + ))}
diff --git a/frontends/wrap-r/src/pages/xpense/account-context.tsx b/frontends/wrap-r/src/pages/xpense/account-context.tsx new file mode 100644 index 00000000..7689dce5 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/account-context.tsx @@ -0,0 +1,198 @@ +import { Shade, createComponent, LocationService, Router } from '@furystack/shades' +import { xpense } from 'common-models' +import { XpenseApiService } from 'common-frontend-utils' +import { match } from 'path-to-regexp' +import { Init } from '../init' +import { AccountDashboard } from './account-dashboard' +import { ReplenishPage } from './replenish' +import { XpenseShoppingPage } from './shopping' +import { AccountHistory } from './account-history' +import { ItemDetails } from './item-details' +import { ShoppingDetails } from './shopping-details' +import { ShopDetails } from './shop-details' +import { ReplenishmentDetails } from './replenishment-details' + +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: [], + shops: [], + }), + constructed: ({ injector, updateState, getState }) => { + const subscriptions = [ + injector.getInstance(LocationService).onLocationChanged.subscribe(async (l) => { + const matcher = match<{ type: 'user' | 'organization'; owner: string; accountName: string }>( + '/xpense/:type/:owner/:accountName', + { + end: false, + decode: decodeURIComponent, + }, + ) + const matched = matcher(l.pathname) + if (matched) { + const { account } = getState() + if ( + !account || + account.name !== matched.params.accountName || + account.ownerName !== matched.params.owner || + account.ownerType !== matched.params.type + ) { + updateState({ isLoading: true }) + try { + const loadedAccount = await injector.getInstance(XpenseApiService).call({ + method: 'GET', + action: '/:type/:owner/:accountName', + url: matched.params, + }) + updateState({ account: loadedAccount }) + } catch (error) { + updateState({ error }) + } finally { + updateState({ isLoading: false }) + } + } + } + }, true), + ] + injector + .getInstance(XpenseApiService) + .call({ method: 'GET', action: '/items' }) + .then((items) => { + updateState({ items }) + }) + injector + .getInstance(XpenseApiService) + .call({ method: 'GET', action: '/shops' }) + .then((shops) => { + updateState({ shops }) + }) + return () => subscriptions.map((s) => s.dispose()) + }, + render: ({ props, getState, updateState }) => { + const { account, isLoading, error, shops, items } = getState() + if (isLoading) { + return + } + if (error || !account) { + return ( +
+
+

WhoOoOops... 😱

+

Failed to load account '{props.accountName}' 😓

+

There was a trouble loading the account.

+
+ Reload page +
+ ) + } + return ( +
+ ( + { + const { account: acc } = getState() + if (acc) { + updateState({ + account: { + ...acc, + current: acc.current + replenishment.amount, + history: [ + ...acc.history, + { + balance: acc.current + replenishment.amount, + change: replenishment.amount, + date: new Date().toISOString(), + relatedEntry: { + replenishmentId: replenishment._id, + }, + }, + ], + }, + }) + } + }} + /> + ), + }, + { + url: '/xpense/:type/:owner/:accountName/shopping', + component: () => ( + { + 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: '/xpense/:type/:owner/:accountName/shopping/:shoppingId', + component: ({ match: m }) => ( + + ), + }, + { + url: '/xpense/:type/:owner/:accountName/replenishment/:replenishmentId', + component: ({ match: m }) => , + }, + { + url: '/xpense/:type/:owner/:accountName/shop/:shopId', + component: ({ match: m }) => , + }, + { + url: '/xpense/:type/:owner/:accountName/item/:itemId', + component: ({ match: m }) => ( + i._id === m.params.itemId) as xpense.Item} /> + ), + }, + { 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 new file mode 100644 index 00000000..56d9dd8f --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/account-dashboard.tsx @@ -0,0 +1,47 @@ +import { Shade, createComponent } from '@furystack/shades' +import { xpense } from 'common-models' +import { Widget } from '../welcome-page' +import { AccountSelector } from './components/account-selector' +import { SelectedAccountHeader } from './components/header' + +export const AccountDashboard = Shade({ + shadowDomName: 'xpense-account-dashboard', + render: ({ props }) => { + const accountUriFragment = encodeURI(`${props.ownerType}/${props.ownerName}/${props.name}`) + return ( +
+
+ +
+ history.pushState({}, '', `/xpense/${account}`)} + currentAccount={props} + /> +
+
+ + + +
+
+ ) + }, +}) 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..74aaf02f --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/account-history.tsx @@ -0,0 +1,88 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Chart, ChartPoint } from 'chart.js' +import { xpense } from 'common-models' +import { colors, Button } from 'common-components' + +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: { + legend: { + display: false, + }, + onClick: (_ev, el) => { + if (!(el as any)[0]) { + return + } + const ev: xpense.Account['history'][number] = props.account.history[(el as any)[0]?._index as number] + const { relatedEntry } = ev + if (relatedEntry.type === 'replenishment') { + history.pushState( + {}, + '', + `/xpense/${props.account.ownerType}/${props.account.ownerName}/${props.account.name}/replenishment/${relatedEntry.replenishmentId}`, + ) + } else if (relatedEntry.type === 'shopping') { + history.pushState( + {}, + '', + `/xpense/${props.account.ownerType}/${props.account.ownerName}/${props.account.name}/shopping/${relatedEntry.shoppingId}`, + ) + } + }, + elements: { + line: { + cubicInterpolationMode: 'monotone', + }, + point: { + radius: 5, + }, + }, + scales: { + xAxes: [ + { + type: 'time', + time: { + minUnit: 'hour', + }, + }, + ], + }, + }, + }) + }, 100) + }, + render: () => { + return ( +
+
+ +
+ +
+ ) + }, +}) 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..9c2248e6 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/add-account.tsx @@ -0,0 +1,46 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Input, Button, styles } from 'common-components/src' +import { XpenseApiService } from 'common-frontend-utils/src' +import { AvailableAccountsContext } from './services/available-accounts-context' + +export const AddXpenseAccountPage = Shade<{}, { name: string; description: string; icon: string }>({ + getInitialState: () => ({ name: '', description: '', icon: '💳' }), + render: ({ injector, getState, updateState }) => { + return ( +
+

Create new account

+
{ + ev.preventDefault() + const account = getState() + injector + .getInstance(XpenseApiService) + .call({ + method: 'POST', + action: '/accounts', + body: account, + }) + .then(() => { + injector.getInstance(AvailableAccountsContext).reload() + history.pushState({}, '', '/xpense') + }) + }}> + updateState({ icon: value }, true)} /> + updateState({ name: value }, true)} /> + updateState({ description: (ev.target as any).value }, true)} + /> + + +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/components/account-selector.tsx b/frontends/wrap-r/src/pages/xpense/components/account-selector.tsx new file mode 100644 index 00000000..7dfd941f --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/components/account-selector.tsx @@ -0,0 +1,57 @@ +import { Shade, createComponent, LocationService } from '@furystack/shades' +import { Autocomplete, Button } from 'common-components' +import { xpense } from 'common-models' +import { AvailableAccountsContext } from '../services/available-accounts-context' + +export const AccountSelector = Shade< + { currentAccount?: xpense.Account; onSelectAccount?: (accountUrlFragment: string) => void }, + { + selectedAccountName?: string + accountNames: string[] + } +>({ + getInitialState: ({ props }) => ({ + accounts: [], + accountNames: [], + selectedAccountName: props.currentAccount + ? `${props.currentAccount.ownerType}/${props.currentAccount.ownerName}/${props.currentAccount.name}` + : '', + }), + constructed: async ({ injector, updateState }) => { + const accounts = await injector.getInstance(AvailableAccountsContext).accounts + updateState({ + accountNames: accounts.map((a) => `${a.ownerType}/${a.ownerName}/${a.name}`), + }) + }, + render: ({ injector, updateState, getState, props }) => { + const { accountNames, selectedAccountName } = getState() + + return ( +
+ { + props.onSelectAccount && props.onSelectAccount(encodeURI(account)) + updateState({ selectedAccountName: account }) + }} + /> + +
+ ) + }, +}) 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..e8e90f63 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/components/header.tsx @@ -0,0 +1,32 @@ +import { Shade, createComponent, RouteLink } from '@furystack/shades' +import { xpense } from 'common-models' + +export const SelectedAccountHeader = Shade<{ area: string; account: xpense.Account }>({ + render: ({ props }) => { + return ( +
+

{props.area}

+
+ {' '} + + 💳 {props.account.name} + + {props.account.ownerType === 'user' ? ( + + {' '} + 🙅‍♂️ {props.account.ownerName}{' '} + + ) : ( + + {' '} + 🏢 {props.account.ownerName}{' '} + + )} +   💲{props.account.current.toString()}{' '} +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx new file mode 100644 index 00000000..996d36ec --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/components/shopping-entry.tsx @@ -0,0 +1,114 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Input, Autocomplete, Button } from 'common-components' +import { xpense } from 'common-models' + +export interface ShoppingEntry { + name: string + amount: number + unitPrice: number + totalPrice: number +} + +export interface ShoppingEntryProps { + entry: ShoppingEntry + items: xpense.Item[] + onChange: (updated: ShoppingEntry) => void + onRemove: (entry: ShoppingEntry) => void +} + +export const ShoppingEntryRow = Shade({ + shadowDomName: 'xpense-shopping-entry', + render: ({ props, element }) => { + const getUnitPriceInput = () => element.querySelector('input[name=unitPrice]') as HTMLInputElement + const getTotalPriceInput = () => element.querySelector('input[name=totalPrice]') as HTMLInputElement + const getAmountInput = () => element.querySelector('input[name=amount]') as HTMLInputElement + const getNameInput = () => element.querySelector('input[name=entryName]') as HTMLInputElement + + const getUnitPrice = () => parseFloat(getUnitPriceInput().value) + const getTotalPrice = () => parseFloat(getTotalPriceInput().value) + const getAmount = () => parseFloat(getAmountInput().value) + const getName = () => getNameInput().value + + const setUnitPrice = (newValue: number) => parseFloat((getUnitPriceInput().value = newValue.toString())) + const setTotalPrice = (newValue: number) => parseFloat((getTotalPriceInput().value = newValue.toString())) + const setAmount = (newValue: number) => parseFloat((getAmountInput().value = newValue.toString())) + + const getEntriesFromInputs: () => ShoppingEntry = () => ({ + name: getName(), + amount: getAmount(), + totalPrice: getTotalPrice(), + unitPrice: getUnitPrice(), + }) + + return ( +
+ i.name)} + onchange={(value) => { + props.onChange({ ...props.entry, name: value }) + }} + /> + { + const valueNo = parseFloat(value) + const totalPrice = valueNo * getAmount() + setUnitPrice(valueNo) + setTotalPrice(totalPrice) + props.onChange({ + ...getEntriesFromInputs(), + unitPrice: valueNo, + totalPrice, + }) + }} + /> + { + const amountNo = parseFloat(value) + const unitPrice = getUnitPrice() + const totalPrice = unitPrice * amountNo + setTotalPrice(totalPrice) + setAmount(amountNo) + props.onChange({ + ...getEntriesFromInputs(), + unitPrice, + amount: amountNo, + totalPrice, + }) + }} + /> + { + const totalPriceNo = parseFloat(value) + const unitPrice = totalPriceNo / getAmount() + setUnitPrice(unitPrice) + props.onChange({ + ...getEntriesFromInputs(), + totalPrice: totalPriceNo, + }) + }} + /> + +
+ ) + }, +}) 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..f82a609e --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/index.tsx @@ -0,0 +1,68 @@ +import { Shade, createComponent, Router } from '@furystack/shades' +import { styles, Fab, colors } from 'common-components' +import { Widget } from '../welcome-page' +import { AccountContext } from './account-context' +import { AvailableAccountsContext, AvailableAccount } from './services/available-accounts-context' + +export const XpensePage = Shade< + { accountOwner?: string; accountType?: 'user' | 'organization'; accountName: string }, + { availableAccounts?: AvailableAccount[] } +>({ + getInitialState: () => ({ availableAccounts: [] }), + constructed: ({ injector, updateState }) => { + injector + .getInstance(AvailableAccountsContext) + .accounts.then((availableAccounts) => updateState({ availableAccounts })) + }, + render: ({ getState }) => { + return ( +
+ ( +
+
+ {getState().availableAccounts?.map((account, index) => ( + + ))} + history.pushState({}, '', '/xpense/add-account')}> + ➕ + +
+
+ )} + routes={[ + { + routingOptions: { end: false }, + url: '/xpense/:type/:owner/:accountName', + component: ({ match }) => ( + + ), + }, + ]} + /> +
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/item-details.tsx b/frontends/wrap-r/src/pages/xpense/item-details.tsx new file mode 100644 index 00000000..0f033a14 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/item-details.tsx @@ -0,0 +1,25 @@ +import { Shade, createComponent } from '@furystack/shades' +import { xpense } from 'common-models' +import { SelectedAccountHeader } from './components/header' +import { AccountSelector } from './components/account-selector' + +export const ItemDetails = Shade<{ item: xpense.Item; account: xpense.Account }>({ + shadowDomName: 'xpense-item-details', + render: ({ props }) => { + return ( +
+
+ +
+ history.pushState({}, '', `/xpense/${account}`)} + currentAccount={props.account} + /> +
+
+          {JSON.stringify(props.item, undefined, 2)}
+        
+
+ ) + }, +}) 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..dec8d084 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/replenish.tsx @@ -0,0 +1,93 @@ +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 { SelectedAccountHeader } from './components/header' + +export const ReplenishPage = Shade< + xpense.Account & { onReplenished: (replenishment: xpense.Replenishment) => void }, + { amount: number; comment?: string; error?: Error } +>({ + getInitialState: () => ({ + amount: 0, + }), + shadowDomName: 'replenish-page', + render: ({ props, getState, updateState, injector }) => { + const { error } = getState() + if (error) { + return ( +
+
+

WhoOoOops... 😱

+

Failed to add to the balance 😓

+

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

+
{JSON.stringify(error)}
+
+ Go to home... +
+ ) + } + return ( +
+ +
{ + ev.preventDefault() + try { + 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 }) + } + }}> + { + updateState({ amount: parseInt((ev.target as any).value as string, 10) }, true) + }} + required + /> + { + updateState({ comment: (ev.target as any).value }, true) + }} + /> + + +
+
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/replenishment-details.tsx b/frontends/wrap-r/src/pages/xpense/replenishment-details.tsx new file mode 100644 index 00000000..31efb140 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/replenishment-details.tsx @@ -0,0 +1,65 @@ +import { Shade, createComponent } from '@furystack/shades' +import { XpenseApiService, getErrorMessage } from 'common-frontend-utils' +import { xpense } from 'common-models' +import { Init } from '../init' + +export const ReplenishmentDetails = Shade< + { replenishmentId: string }, + { isLoading: boolean; replenishment?: xpense.Replenishment; error?: string } +>({ + getInitialState: () => ({ + isLoading: true, + }), + shadowDomName: 'xpense-replenishment-details', + constructed: async ({ injector, props, updateState }) => { + try { + const replenishment = await injector.getInstance(XpenseApiService).call({ + method: 'GET', + action: '/replenishments/:replenishmentId', + url: { replenishmentId: props.replenishmentId }, + }) + updateState({ replenishment }) + } catch (error) { + const errorMessage = await getErrorMessage(error) + updateState({ error: errorMessage }) + } finally { + updateState({ isLoading: false }) + } + }, + render: ({ getState }) => { + const { isLoading, replenishment, error } = getState() + if (isLoading) { + return + } + if (error || !replenishment) { + return ( +
+
+

WhoOoOops... 😱

+

Failed to load replenishment 😓

+
+ Reload page +
+ ) + } + return ( +
+        {JSON.stringify(replenishment, undefined, 2)}
+      
+ ) + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/services/available-accounts-context.ts b/frontends/wrap-r/src/pages/xpense/services/available-accounts-context.ts new file mode 100644 index 00000000..54e99400 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/services/available-accounts-context.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@furystack/inject' +import { XpenseApiService } from 'common-frontend-utils' + +export interface AvailableAccount { + name: string + icon?: string + ownerType: 'user' | 'organization' + ownerName: string + current: number +} + +@Injectable({ lifetime: 'singleton' }) +export class AvailableAccountsContext { + public accounts!: Promise + + public reload() { + this.accounts = this.api.call({ + method: 'GET', + action: '/availableAccounts', + }) + } + + constructor(private readonly api: XpenseApiService) { + this.reload() + } +} diff --git a/frontends/wrap-r/src/pages/xpense/shop-details.tsx b/frontends/wrap-r/src/pages/xpense/shop-details.tsx new file mode 100644 index 00000000..406719df --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/shop-details.tsx @@ -0,0 +1,10 @@ +import { Shade, createComponent } from '@furystack/shades' +import { xpense } from 'common-models' +import { Init } from '../init' + +export const ShopDetails = Shade<{ shopId: string; account: xpense.Account; items: xpense.Item[] }>({ + shadowDomName: 'xpense-shop-details', + render: () => { + return + }, +}) diff --git a/frontends/wrap-r/src/pages/xpense/shopping-details.tsx b/frontends/wrap-r/src/pages/xpense/shopping-details.tsx new file mode 100644 index 00000000..0cdecfc9 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/shopping-details.tsx @@ -0,0 +1,63 @@ +import { Shade, createComponent } from '@furystack/shades' +import { xpense } from 'common-models' +import { XpenseApiService, getErrorMessage } from 'common-frontend-utils' +import { Init } from '../init' + +export const ShoppingDetails = Shade< + { account: xpense.Account; shoppingId: string; items: xpense.Item[] }, + { isLoading: boolean; shopping?: xpense.Shopping; error?: string } +>({ + getInitialState: () => ({ isLoading: true }), + constructed: async ({ injector, props, updateState }) => { + try { + const shopping = await injector.getInstance(XpenseApiService).call({ + method: 'GET', + action: '/shopping/:shoppingId', + url: { shoppingId: props.shoppingId }, + }) + updateState({ shopping }) + } catch (error) { + const errorMessage = await getErrorMessage(error) + updateState({ error: errorMessage }) + } finally { + updateState({ isLoading: false }) + } + }, + shadowDomName: 'xpense-shopping-details', + render: ({ getState }) => { + const { isLoading, error, shopping } = getState() + if (isLoading) { + return + } + if (error || !shopping) { + return ( +
+
+

WhoOoOops... 😱

+

Failed to load shopping info 😓

+
+ Reload page +
+ ) + } + return ( +
+        {JSON.stringify(shopping, undefined, 2)}
+      
+ ) + }, +}) 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..aad71699 --- /dev/null +++ b/frontends/wrap-r/src/pages/xpense/shopping.tsx @@ -0,0 +1,160 @@ +import { Shade, createComponent } from '@furystack/shades' +import { styles, Autocomplete, Button, Input, colors } from 'common-components' +import { xpense } from 'common-models' +import { XpenseApiService } from 'common-frontend-utils' +import { Init } from '../init' +import { AccountSelector } from './components/account-selector' +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: xpense.Shopping) => void }, + { entries: ShoppingEntry[]; shopName: string; error?: Error; isSaveInProgress?: boolean } +>({ + shadowDomName: 'xpense-shopping-page', + getInitialState: () => ({ + shopName: '', + entries: [], + }), + render: ({ props, getState, updateState, element, injector }) => { + const { error, isSaveInProgress } = getState() + + const updateTotal = () => { + const { entries } = getState() + const total = entries.reduce((last, current) => last + (current.totalPrice || 0), 0) + ;(element.querySelector('#total') as HTMLInputElement).value = total.toString() + return total + } + + if (isSaveInProgress) { + return + } + + if (error) { + return ( +
+
+

WhoOoOops... 😱

+

Something really really bad happened... 😓

+

Something went wrong during saving your shopping. '{props.name}'

+
{JSON.stringify(error)}
+
+ Go to home... +
+ ) + } + + return ( +
+
+ +
+ history.pushState({}, '', `/xpense/${acc}/shopping`)} + currentAccount={props} + /> +
+
{ + 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 }) + const shopping = await injector.getInstance(XpenseApiService).call({ + method: 'POST', + action: '/:type/:owner/:accountName/shop', + url: { type: props.ownerType, owner: props.ownerName, accountName: props.name }, + body: { + shopName: getState().shopName, + entries: getState().entries.map((e) => ({ + itemName: e.name, + amount: e.amount, + unitPrice: e.unitPrice, + })), // as Array<{ itemName: string; amount: number; unitPrice: number }>, + }, + }) + props.onShopped(shopping) + history.pushState({}, '', `/xpense/${props.ownerType}/${props.ownerName}/${props.name}`) + } catch (e) { + updateState({ error: e }) + } finally { + updateState({ isSaveInProgress: false }) + } + }}> + 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) => ( + { + updateState({ + entries: getState().entries.filter((e) => e !== getState().entries[index]), + }) + updateTotal() + }} + onChange={(change) => { + const { entries } = getState() + entries[index] = change + updateState({ entries }, true) + updateTotal() + }} + /> + ))} +
+
+ +
+ + +
+ +
+ ) + }, +}) diff --git a/frontends/wrap-r/src/services/github-auth-provider.ts b/frontends/wrap-r/src/services/github-auth-provider.ts index 133eade5..de48544a 100644 --- a/frontends/wrap-r/src/services/github-auth-provider.ts +++ b/frontends/wrap-r/src/services/github-auth-provider.ts @@ -1,6 +1,6 @@ import { Injectable } from '@furystack/inject' import { WrapRApiService, SessionService } from 'common-frontend-utils' -import { tokens } from 'common-config/src' +import { tokens } from 'common-config' @Injectable({ lifetime: 'singleton' }) export class GithubAuthProvider { diff --git a/frontends/wrap-r/webpack.common.js b/frontends/wrap-r/webpack.common.js index 26cb0b38..af1c6a28 100644 --- a/frontends/wrap-r/webpack.common.js +++ b/frontends/wrap-r/webpack.common.js @@ -16,10 +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_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, + 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, 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