diff --git a/adminSiteClient/EditorBasicTab.tsx b/adminSiteClient/EditorBasicTab.tsx index 04f05a9cead..1f48d420f64 100644 --- a/adminSiteClient/EditorBasicTab.tsx +++ b/adminSiteClient/EditorBasicTab.tsx @@ -13,6 +13,7 @@ import { StackMode, ALL_GRAPHER_CHART_TYPES, GrapherChartType, + GRAPHER_CHART_TYPES, } from "@ourworldindata/types" import { DimensionSlot, @@ -109,7 +110,7 @@ class DimensionSlotView< const { selection } = grapher const { availableEntityNames, availableEntityNameSet } = selection - if (grapher.isScatter || grapher.isSlopeChart || grapher.isMarimekko) { + if (grapher.isScatter || grapher.isMarimekko) { // chart types that display all entities by default shouldn't select any by default selection.clearSelection() } else if ( @@ -367,13 +368,17 @@ export class EditorBasicTab< ? [] : [value as GrapherChartType] + if (grapher.isLineChart) { + this.addSlopeChart() + } + if (grapher.isMarimekko) { grapher.hideRelativeToggle = false grapher.stackMode = StackMode.relative } - // Give scatterplots and slope charts a default color dimension if they don't have one - if (grapher.isScatter || grapher.isSlopeChart) { + // Give scatterplots a default color and size dimensions + if (grapher.isScatter) { const hasColor = grapher.dimensions.find( (d) => d.property === DimensionProperty.color ) @@ -382,10 +387,7 @@ export class EditorBasicTab< variableId: CONTINENTS_INDICATOR_ID, property: DimensionProperty.color, }) - } - // Give scatterplots a default size dimension if they don't have one - if (grapher.isScatter) { const hasSize = grapher.dimensions.find( (d) => d.property === DimensionProperty.size ) @@ -417,6 +419,32 @@ export class EditorBasicTab< ] } + private addSlopeChart(): void { + const { grapher } = this.props.editor + if (grapher.hasSlopeChart) return + grapher.chartTypes = [ + ...grapher.chartTypes, + GRAPHER_CHART_TYPES.SlopeChart, + ] + } + + private removeSlopeChart(): void { + const { grapher } = this.props.editor + grapher.chartTypes = grapher.chartTypes.filter( + (type) => type !== GRAPHER_CHART_TYPES.SlopeChart + ) + } + + @action.bound toggleSecondarySlopeChart( + shouldHaveSlopeChart: boolean + ): void { + if (shouldHaveSlopeChart) { + this.addSlopeChart() + } else { + this.removeSlopeChart() + } + } + render() { const { editor } = this.props const { grapher } = editor @@ -441,6 +469,13 @@ export class EditorBasicTab< (grapher.hasMapTab = shouldHaveMapTab) } /> + {grapher.isLineChart && ( + + )} {!isIndicatorChart && ( diff --git a/adminSiteClient/EditorFeatures.tsx b/adminSiteClient/EditorFeatures.tsx index 2d9761add1c..70adfd194bf 100644 --- a/adminSiteClient/EditorFeatures.tsx +++ b/adminSiteClient/EditorFeatures.tsx @@ -62,6 +62,7 @@ export class EditorFeatures { @computed get hideLegend() { return ( this.grapher.isLineChart || + this.grapher.isSlopeChart || this.grapher.isStackedArea || this.grapher.isStackedDiscreteBar ) @@ -118,9 +119,9 @@ export class EditorFeatures { return true } - // for line charts, specifying a missing data strategy only makes sense + // for line and slope charts, specifying a missing data strategy only makes sense // if there are multiple entities - if (this.grapher.isLineChart) { + if (this.grapher.isLineChart || this.grapher.isSlopeChart) { return ( this.grapher.canChangeEntity || this.grapher.canSelectMultipleEntities diff --git a/baker/updateChartEntities.ts b/baker/updateChartEntities.ts index 49e582a71d8..cbaf6a74107 100644 --- a/baker/updateChartEntities.ts +++ b/baker/updateChartEntities.ts @@ -106,7 +106,7 @@ const obtainAvailableEntitiesForGrapherConfig = async ( // In these chart types, an unselected entity is still shown const chartTypeShowsUnselectedEntities = - grapher.isScatter || grapher.isSlopeChart || grapher.isMarimekko + grapher.isScatter || grapher.isMarimekko if (canChangeEntities || chartTypeShowsUnselectedEntities) return grapher.tableForSelection.availableEntityNames as string[] diff --git a/db/migration/1732195571407-RemoveColorDimensionFromSlopeCharts.ts b/db/migration/1732195571407-RemoveColorDimensionFromSlopeCharts.ts new file mode 100644 index 00000000000..1750d7e2576 --- /dev/null +++ b/db/migration/1732195571407-RemoveColorDimensionFromSlopeCharts.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class RemoveColorDimensionFromSlopeCharts1732195571407 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // remove color dimension for all slope charts + // the y-dimension always comes first and the color dimension second, + // so it's safe to keep the first dimension only + await queryRunner.query(` + -- sql + UPDATE chart_configs + SET + patch = JSON_REPLACE(patch, '$.dimensions', JSON_ARRAY(patch -> '$.dimensions[0]')), + full = JSON_REPLACE(full, '$.dimensions', JSON_ARRAY(full -> '$.dimensions[0]')) + WHERE + chartType = 'SlopeChart' + `) + + // remove the color dimension for slope charts from the chart_dimensions table + await queryRunner.query(` + -- sql + DELETE cd FROM chart_dimensions cd + JOIN charts c ON c.id = cd.chartId + JOIN chart_configs cc ON c.configId = cc.id + WHERE cc.chartType = 'SlopeChart' AND cd.property = 'color' + `) + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async down(): Promise {} +} diff --git a/db/migration/1732291572062-MigrateSlopeCharts.ts b/db/migration/1732291572062-MigrateSlopeCharts.ts new file mode 100644 index 00000000000..6fd86122128 --- /dev/null +++ b/db/migration/1732291572062-MigrateSlopeCharts.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class MigrateSlopeCharts1732291572062 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // create a temporary table that lists all slope charts and their + // corresponding line charts (there might be multiple) + await queryRunner.query(` + -- sql + CREATE TABLE slope_line_charts ( + variableId integer NOT NULL, + slopeChartId integer NOT NULL, + slopeChartConfigId varchar(255) NOT NULL, + slopeChartSelectedEntityNames JSON, + lineChartId integer, + lineChartConfigId varchar(255) + ) + `) + await queryRunner.query(` + INSERT INTO slope_line_charts ( + variableId, + slopeChartId, + slopeChartConfigId, + slopeChartSelectedEntityNames, + lineChartId, + lineChartConfigId + ) + SELECT * FROM ( + WITH line_charts AS ( + SELECT + c.id, + c.configId, + cc.full ->> '$.dimensions[0].variableId' as variableId + FROM charts c + JOIN chart_configs cc ON c.configId = cc.id + WHERE + cc.chartType = 'LineChart' + AND JSON_LENGTH(cc.full, '$.dimensions') = 1 + AND cc.full ->> '$.isPublished' = 'true' + ), slope_charts AS ( + SELECT + c.id, + c.configId, + cc.full ->> '$.dimensions[0].variableId' as variableId, + cc.full -> '$.selectedEntityNames' as selectedEntityNames + FROM charts c + JOIN chart_configs cc ON c.configId = cc.id + WHERE + cc.chartType = 'SlopeChart' + AND cc.full ->> '$.isPublished' = 'true' + ) + SELECT + sc.variableId AS variableId, + sc.id AS slopeChartId, + sc.configId AS slopeChartConfigId, + sc.selectedEntityNames AS slopeChartSelectedEntityNames, + lc.id AS lineChartId, + lc.configId AS lineChartConfigId + FROM slope_charts sc + LEFT JOIN line_charts lc ON lc.variableId = sc.variableId + ) AS derived_table; + `) + + // STAND-ALONE SLOPE CHARTS + + // make sure entity selection is not disabled + await queryRunner.query(` + -- sql + UPDATE chart_configs cc + JOIN slope_line_charts slc ON slc.slopeChartConfigId = cc.id + SET + cc.patch = JSON_SET(cc.patch, '$.addCountryMode', 'add-country'), + cc.full = JSON_SET(cc.full, '$.addCountryMode', 'add-country') + WHERE + slc.lineChartId IS NULL + AND ( + cc.full ->> '$.addCountryMode' = 'disabled' + OR cc.full ->> '$.addCountryMode' = 'change-country' + ) + `) + + // make sure the line legend isn't hidden + await queryRunner.query(` + -- sql + UPDATE chart_configs cc + JOIN slope_line_charts slc ON slc.slopeChartConfigId = cc.id + SET + cc.patch = JSON_SET(cc.patch, '$.hideLegend', false), + cc.full = JSON_SET(cc.full, '$.hideLegend', false) + WHERE + slc.lineChartId IS NULL + AND cc.full ->> '$.hideLegend' = 'true' + `) + + // for stand-alone slope charts that don't currently have any selected + // entities, just pick a random set of five entities. + // it's possible to end up with entities that don't have data for the + // selected years. after running the migration, I'll go through each + // slope chart and correct the selected entities manually if necessary. + await queryRunner.query(` + -- sql + WITH selected_entities AS ( + WITH ranked_entities AS ( + SELECT + slc.slopeChartId AS chartId, + slc.slopeChartConfigId AS configId, + cxe.entityId, + e.name AS entityName, + ROW_NUMBER() OVER (PARTITION BY chartId ORDER BY RAND()) AS randomIndex + FROM slope_line_charts slc + JOIN charts_x_entities cxe ON cxe.chartId = slc.slopeChartId + JOIN entities e ON e.id = cxe.entityId + WHERE + slc.lineChartId IS NULL + AND ( + slc.slopeChartSelectedEntityNames IS NULL + OR JSON_LENGTH(slc.slopeChartSelectedEntityNames) = 0 + ) + ) + SELECT chartId, configId, JSON_ARRAYAGG(entityName) as selectedEntityNames + FROM ranked_entities + WHERE randomIndex <= 4 + GROUP BY chartId, configId + ) + UPDATE chart_configs cc + JOIN selected_entities se ON se.configId = cc.id + SET + cc.patch = JSON_SET(cc.patch, '$.selectedEntityNames', se.selectedEntityNames), + cc.full = JSON_SET(cc.full, '$.selectedEntityNames', se.selectedEntityNames) + `) + + // LINE+SLOPE CHARTS + + // add a slope tab to all line charts that have a corresponding slope + // chart (excluded are slope charts that have been matched with more + // than one line chart) + await queryRunner.query(` + WITH deduped_slope_line_charts AS ( + SELECT slopeChartId, COUNT(*) count + FROM slope_line_charts + GROUP BY slopeChartId + HAVING count = 1 + ) + UPDATE chart_configs cc + JOIN slope_line_charts slc ON slc.lineChartConfigId = cc.id + JOIN deduped_slope_line_charts dslc ON dslc.slopeChartId = slc.slopeChartId + SET + cc.patch = JSON_SET(cc.patch, '$.chartTypes', JSON_ARRAY('LineChart', 'SlopeChart')), + cc.full = JSON_SET(cc.full, '$.chartTypes', JSON_ARRAY('LineChart', 'SlopeChart')) + `) + + await queryRunner.query(`DROP TABLE slope_line_charts`) + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async down(): Promise {} +} diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 40da7cba3dc..64b355d867e 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -305,12 +305,22 @@ export abstract class AbstractCoreColumn { @imemo get displayName(): string { return ( this.display?.name ?? - this.def.presentation?.titlePublic ?? // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + this.def.presentation?.titlePublic ?? this.name ?? "" ) } + @imemo get nonEmptyDisplayName(): string { + return ( + this.display?.name || + // this is a bit of an unusual fallback - if display.name is not given, titlePublic is the next best thing before name + this.def.presentation?.titlePublic || + this.nonEmptyName + ) + } + @imemo get titlePublicOrDisplayName(): IndicatorTitleWithFragments { return this.def.presentation?.titlePublic ? { @@ -526,14 +536,16 @@ export abstract class AbstractCoreColumn { // assumes table is sorted by time @imemo get owidRows(): OwidVariableRow[] { const entities = this.allEntityNames - const times = this.originalTimes + const times = this.allTimes const values = this.values + const originalTimes = this.originalTimes const originalValues = this.originalValues - return range(0, times.length).map((index) => { + return range(0, originalTimes.length).map((index) => { return omitUndefinedValues({ entityName: entities[index], time: times[index], value: values[index], + originalTime: originalTimes[index], originalValue: originalValues[index], }) }) @@ -552,6 +564,23 @@ export abstract class AbstractCoreColumn { return map } + // todo: remove? Should not be on CoreTable + @imemo get owidRowByEntityNameAndTime(): Map< + EntityName, + Map> + > { + const valueByEntityNameAndTime = new Map< + EntityName, + Map> + >() + this.owidRows.forEach((row) => { + if (!valueByEntityNameAndTime.has(row.entityName)) + valueByEntityNameAndTime.set(row.entityName, new Map()) + valueByEntityNameAndTime.get(row.entityName)!.set(row.time, row) + }) + return valueByEntityNameAndTime + } + // todo: remove? Should not be on CoreTable // NOTE: this uses the original times, so any tolerance is effectively unapplied. @imemo get valueByEntityNameAndOriginalTime(): Map< @@ -567,7 +596,7 @@ export abstract class AbstractCoreColumn { valueByEntityNameAndTime.set(row.entityName, new Map()) valueByEntityNameAndTime .get(row.entityName)! - .set(row.time, row.value) + .set(row.originalTime, row.value) }) return valueByEntityNameAndTime } diff --git a/packages/@ourworldindata/core-table/src/OwidTable.test.ts b/packages/@ourworldindata/core-table/src/OwidTable.test.ts index a58250a1ec7..32208310965 100755 --- a/packages/@ourworldindata/core-table/src/OwidTable.test.ts +++ b/packages/@ourworldindata/core-table/src/OwidTable.test.ts @@ -72,7 +72,7 @@ it("can parse data to Javascript data structures", () => { table.get("Population").owidRows.forEach((row) => { expect(typeof row.entityName).toBe("string") expect(row.value).toBeGreaterThan(100) - expect(row.time).toBeGreaterThan(1999) + expect(row.originalTime).toBeGreaterThan(1999) }) }) @@ -632,7 +632,7 @@ describe("tolerance", () => { }) }) -it("assigns originalTime as 'time' in owidRows", () => { +it("assigns originalTime as 'originalTime' in owidRows", () => { const csv = `gdp,year,entityName,entityId,entityCode 1000,2019,USA,, 1001,2020,UK,,` @@ -642,7 +642,7 @@ it("assigns originalTime as 'time' in owidRows", () => { expect.not.arrayContaining([ expect.objectContaining({ entityName: "USA", - time: 2020, + originalTime: 2020, value: 1000, }), ]) @@ -651,7 +651,7 @@ it("assigns originalTime as 'time' in owidRows", () => { expect.not.arrayContaining([ expect.objectContaining({ entityName: "UK", - time: 2019, + originalTime: 2019, value: 1001, }), ]) diff --git a/packages/@ourworldindata/explorer/src/GrapherGrammar.ts b/packages/@ourworldindata/explorer/src/GrapherGrammar.ts index 78d6a82344a..9c12109ffd0 100644 --- a/packages/@ourworldindata/explorer/src/GrapherGrammar.ts +++ b/packages/@ourworldindata/explorer/src/GrapherGrammar.ts @@ -3,6 +3,7 @@ import { ColorSchemeName, FacetAxisDomain, FacetStrategy, + GRAPHER_CHART_TYPES, GRAPHER_TAB_OPTIONS, MissingDataStrategy, StackMode, @@ -66,10 +67,11 @@ export const GrapherGrammar: Grammar = { description: `The type of chart to show such as LineChart or ScatterPlot. If set to None, then the chart tab is hidden.`, terminalOptions: toTerminalOptions([ ...ALL_GRAPHER_CHART_TYPES, + `${GRAPHER_CHART_TYPES.LineChart} ${GRAPHER_CHART_TYPES.SlopeChart}`, "None", ]), toGrapherObject: (value) => ({ - chartTypes: value === "None" ? [] : [value], + chartTypes: value === "None" ? [] : value.split(" "), }), }, grapherId: { diff --git a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx index 144ed286209..242ddc08248 100644 --- a/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/barCharts/DiscreteBarChart.tsx @@ -409,11 +409,14 @@ export class DiscreteBarChart {this.placedSeries.map((series) => { return ( - series.label && - series.label.render( - series.entityLabelX, - series.barY - series.label.height / 2, - { textProps: style } + series.label && ( + + {series.label.render( + series.entityLabelX, + series.barY - series.label.height / 2, + { textProps: style } + )} + ) ) })} @@ -990,6 +993,7 @@ function makeProjectedDataPattern(color: string): React.ReactElement { const size = 7 return ( { if (manager.isOnTableTab) return undefined if (manager.isOnMapTab) return GRAPHER_MAP_TYPE if (manager.isOnChartTab) { - return manager.isLineChartThatTurnedIntoDiscreteBar + return manager.isLineChartThatTurnedIntoDiscreteBarActive ? GRAPHER_CHART_TYPES.DiscreteBar : manager.activeChartType } diff --git a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx index 375e9816c7a..501d646456d 100644 --- a/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx +++ b/packages/@ourworldindata/grapher/src/chart/ChartUtils.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Box, getCountryByName } from "@ourworldindata/utils" +import { areSetsEqual, Box, getCountryByName } from "@ourworldindata/utils" import { SeriesStrategy, EntityName, @@ -15,6 +15,7 @@ import { GRAPHER_SIDE_PANEL_CLASS, GRAPHER_TIMELINE_CLASS, GRAPHER_SETTINGS_CLASS, + validChartTypeCombinations, } from "../core/GrapherConstants" export const autoDetectYColumnSlugs = (manager: ChartManager): string[] => { @@ -175,3 +176,15 @@ export function mapChartTypeNameToQueryParam( return GRAPHER_TAB_QUERY_PARAMS.marimekko } } + +export function findValidChartTypeCombination( + chartTypes: GrapherChartType[] +): GrapherChartType[] | undefined { + const chartTypeSet = new Set(chartTypes) + for (const validCombination of validChartTypeCombinations) { + const validCombinationSet = new Set(validCombination) + if (areSetsEqual(chartTypeSet, validCombinationSet)) + return validCombination + } + return undefined +} diff --git a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx index 6c0eaa289a5..8d947e5bb62 100644 --- a/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx +++ b/packages/@ourworldindata/grapher/src/controls/ContentSwitchers.tsx @@ -175,9 +175,11 @@ function TabIcon({ case GRAPHER_TAB_NAMES.WorldMap: return default: - const chartIcon = isLineChartThatTurnedIntoDiscreteBar - ? chartIcons[GRAPHER_CHART_TYPES.DiscreteBar] - : chartIcons[tab] + const chartIcon = + tab === GRAPHER_TAB_NAMES.LineChart && + isLineChartThatTurnedIntoDiscreteBar + ? chartIcons[GRAPHER_CHART_TYPES.DiscreteBar] + : chartIcons[tab] return chartIcon } } @@ -193,9 +195,15 @@ function makeTabLabelText( if (tab === GRAPHER_TAB_NAMES.WorldMap) return "Map" if (!options.hasMultipleChartTypes) return "Chart" + if ( + tab === GRAPHER_TAB_NAMES.LineChart && + options.isLineChartThatTurnedIntoDiscreteBar + ) + return "Bar" + switch (tab) { case GRAPHER_TAB_NAMES.LineChart: - return options.isLineChartThatTurnedIntoDiscreteBar ? "Bar" : "Line" + return "Line" case GRAPHER_TAB_NAMES.SlopeChart: return "Slope" diff --git a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx index 30cbd370147..fb7f4351ce7 100644 --- a/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx +++ b/packages/@ourworldindata/grapher/src/controls/SettingsMenu.tsx @@ -51,6 +51,7 @@ const { StackedDiscreteBar, StackedBar, Marimekko, + SlopeChart, } = GRAPHER_CHART_TYPES export interface SettingsMenuManager @@ -170,6 +171,7 @@ export class SettingsMenu extends React.Component<{ ScatterPlot, LineChart, Marimekko, + SlopeChart, ].includes(this.chartType as any) } diff --git a/packages/@ourworldindata/grapher/src/core/Grapher.tsx b/packages/@ourworldindata/grapher/src/core/Grapher.tsx index 0243e038ba2..1c3d4a9bef2 100644 --- a/packages/@ourworldindata/grapher/src/core/Grapher.tsx +++ b/packages/@ourworldindata/grapher/src/core/Grapher.tsx @@ -66,7 +66,6 @@ import { extractDetailsFromSyntax, omit, isTouchDevice, - areSetsEqual, } from "@ourworldindata/utils" import { MarkdownTextWrap, @@ -138,7 +137,6 @@ import { GRAPHER_FRAME_PADDING_HORIZONTAL, GRAPHER_FRAME_PADDING_VERTICAL, latestGrapherConfigSchema, - validChartTypeCombinations, } from "../core/GrapherConstants" import { loadVariableDataAndMetadata } from "./loadVariable" import Cookies from "js-cookie" @@ -200,6 +198,7 @@ import { ScatterPlotManager } from "../scatterCharts/ScatterPlotChartConstants" import { autoDetectSeriesStrategy, autoDetectYColumnSlugs, + findValidChartTypeCombination, mapChartTypeNameToQueryParam, mapQueryParamToChartTypeName, } from "../chart/ChartUtils" @@ -886,7 +885,10 @@ export class Grapher ) if (this.isOnSlopeChartTab) - return table.filterByTargetTimes([startTime, endTime]) + return table.filterByTargetTimes( + [startTime, endTime], + table.get(this.yColumnSlugs[0]).tolerance + ) return table.filterByTimeRange(startTime, endTime) } @@ -1409,7 +1411,7 @@ export class Grapher if (this.isLineChart || this.isDiscreteBar) return [yAxis, color] else if (this.isScatter) return [yAxis, xAxis, size, color] else if (this.isMarimekko) return [yAxis, xAxis, color] - else if (this.isSlopeChart) return [yAxis, color] + else if (this.isSlopeChart) return [yAxis] return [yAxis] } @@ -1525,21 +1527,31 @@ export class Grapher }) } + @computed get hasProjectedData(): boolean { + return this.inputTable.numericColumnSlugs.some( + (slug) => this.inputTable.get(slug).isProjection + ) + } + @computed get validChartTypes(): GrapherChartType[] { const { chartTypes } = this // all single-chart Graphers are valid if (chartTypes.length <= 1) return chartTypes - const chartTypeSet = new Set(chartTypes) - for (const validCombination of validChartTypeCombinations) { - const validCombinationSet = new Set(validCombination) - if (areSetsEqual(chartTypeSet, validCombinationSet)) - return validCombination - } + // find valid combination in a pre-defined list + const validChartTypes = findValidChartTypeCombination(chartTypes) // if the given combination is not valid, then ignore all but the first chart type - return chartTypes.slice(0, 1) + if (!validChartTypes) return chartTypes.slice(0, 1) + + // projected data is only supported for line charts + const isLineChart = validChartTypes[0] === GRAPHER_CHART_TYPES.LineChart + if (isLineChart && this.hasProjectedData) { + return [GRAPHER_CHART_TYPES.LineChart] + } + + return validChartTypes } @computed get validChartTypeSet(): Set { @@ -1635,7 +1647,7 @@ export class Grapher if (this.shouldAddChangeInPrefixToTitle) text = "Change in " + lowerCaseFirstLetterUnlessAbbreviation(text) - if (this.shouldAddTimeSuffixToTitle) + if (this.shouldAddTimeSuffixToTitle && this.timeTitleSuffix) text = appendAnnotationField(text, this.timeTitleSuffix) return text.trim() @@ -1738,11 +1750,11 @@ export class Grapher return this.xAxis.scaleType } - @computed private get timeTitleSuffix(): string { + @computed private get timeTitleSuffix(): string | undefined { const timeColumn = this.table.timeColumn - if (timeColumn.isMissing) return "" // Do not show year until data is loaded + if (timeColumn.isMissing) return undefined // Do not show year until data is loaded const { startTime, endTime } = this - if (startTime === undefined || endTime === undefined) return "" + if (startTime === undefined || endTime === undefined) return undefined const time = startTime === endTime @@ -1934,7 +1946,7 @@ export class Grapher @computed get typeExceptWhenLineChartAndSingleTimeThenWillBeBarChart(): GrapherChartType { - return this.isLineChartThatTurnedIntoDiscreteBar + return this.isLineChartThatTurnedIntoDiscreteBarActive ? GRAPHER_CHART_TYPES.DiscreteBar : (this.activeChartType ?? GRAPHER_CHART_TYPES.LineChart) } @@ -1993,6 +2005,12 @@ export class Grapher return closestMinTime !== undefined && closestMinTime === closestMaxTime } + @computed get isLineChartThatTurnedIntoDiscreteBarActive(): boolean { + return ( + this.isOnLineChartTab && this.isLineChartThatTurnedIntoDiscreteBar + ) + } + @computed get isOnLineChartTab(): boolean { return this.activeChartType === GRAPHER_CHART_TYPES.LineChart } @@ -2026,7 +2044,7 @@ export class Grapher } @computed get supportsMultipleYColumns(): boolean { - return !(this.isScatter || this.isSlopeChart) + return !this.isScatter } @computed private get xDimension(): ChartDimension | undefined { @@ -3537,6 +3555,7 @@ export class Grapher this.hasChartTab && this.canSelectMultipleEntities && (this.isOnLineChartTab || + this.isOnSlopeChartTab || this.isOnStackedAreaTab || this.isOnStackedBarTab || this.isOnDiscreteBarTab || diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index ebce9d92057..a6590dcf0be 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -453,7 +453,7 @@ export class EntitySelector extends React.Component<{ const rows = column.owidRowsByEntityName.get(entityName) ?? [] searchableEntity[column.slug] = maxBy( rows, - (row) => row.time + (row) => row.originalTime )?.value } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 876a49b57bf..6def600bb85 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -23,7 +23,6 @@ import { AxisAlign, Color, HorizontalAlign, - PrimitiveType, makeIdForHumanConsumption, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" @@ -102,6 +101,13 @@ import { HorizontalColorLegendManager, HorizontalNumericColorLegend, } from "../horizontalColorLegend/HorizontalColorLegends" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "./lineChartHelpers" const LINE_CHART_CLASS_NAME = "LineChart" @@ -707,7 +713,10 @@ export class LineChart rows={sortedData.map((series) => { const { seriesName: name, isProjection: striped } = series - const annotation = this.getAnnotationsForSeries(name) + const annotation = getAnnotationsForSeries( + this.annotationsMap, + name + ) const point = series.points.find( (point) => point.x === target.x @@ -1148,24 +1157,8 @@ export class LineChart // End of color legend props - // todo: for now just works with 1 y column - @computed private get annotationsMap(): Map< - PrimitiveType, - Set - > { - return this.inputTable - .getAnnotationColumnForColumn(this.yColumnSlugs[0]) - ?.getUniqueValuesGroupedBy(this.inputTable.entityNameSlug) - } - - getAnnotationsForSeries(seriesName: SeriesName): string | undefined { - const annotationsMap = this.annotationsMap - const annos = annotationsMap?.get(seriesName) - return annos - ? Array.from(annos.values()) - .filter((anno) => anno) - .join(" & ") - : undefined + @computed private get annotationsMap(): AnnotationsMap | undefined { + return getAnnotationsMap(this.inputTable, this.yColumnSlugs[0]) } @computed private get colorScheme(): ColorScheme { @@ -1196,39 +1189,6 @@ export class LineChart }) } - private getSeriesName( - entityName: EntityName, - columnName: string, - entityCount: number - ): SeriesName { - if (this.seriesStrategy === SeriesStrategy.entity) { - return entityName - } - if (entityCount > 1 || this.manager.canSelectMultipleEntities) { - return `${entityName} - ${columnName}` - } else { - return columnName - } - } - - private getColorKey( - entityName: EntityName, - columnName: string, - entityCount: number - ): SeriesName { - if (this.seriesStrategy === SeriesStrategy.entity) { - return entityName - } - // If only one entity is plotted, we want to use the column colors. - // Unlike in `getSeriesName`, we don't care whether the user can select - // multiple entities, only whether more than one is plotted. - if (entityCount > 1) { - return `${entityName} - ${columnName}` - } else { - return columnName - } - } - // cache value for performance @computed private get rowIndicesByEntityName(): Map { return this.transformedTable.rowIndex([ @@ -1237,14 +1197,20 @@ export class LineChart } private constructSingleSeries( - entityName: string, - col: CoreColumn + entityName: EntityName, + column: CoreColumn ): LineChartSeries { - const { hasColorScale, transformedTable, colorColumn } = this + const { + manager: { canSelectMultipleEntities = false }, + transformedTable: { availableEntityNames }, + seriesStrategy, + hasColorScale, + colorColumn, + } = this // Construct the points - const timeValues = col.originalTimeColumn.valuesIncludingErrorValues - const values = col.valuesIncludingErrorValues + const timeValues = column.originalTimeColumn.valuesIncludingErrorValues + const values = column.valuesIncludingErrorValues const colorValues = colorColumn.valuesIncludingErrorValues // If Y and Color are the same column, we need to get rid of any duplicate rows. // Duplicates occur because Y doesn't have tolerance applied, but Color does. @@ -1269,26 +1235,34 @@ export class LineChart }) // Construct series properties - const totalEntityCount = transformedTable.availableEntityNames.length - const seriesName = this.getSeriesName( + const columnName = column.nonEmptyDisplayName + const seriesName = getSeriesName({ entityName, - col.displayName, - totalEntityCount - ) + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, + }) + let seriesColor: Color if (hasColorScale) { const colorValue = last(points)?.colorValue seriesColor = this.getColorScaleColor(colorValue) } else { seriesColor = this.categoricalColorAssigner.assign( - this.getColorKey(entityName, col.displayName, totalEntityCount) + getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + }) ) } return { points, seriesName, - isProjection: col.isProjection, + isProjection: column.isProjection, color: seriesColor, } } @@ -1350,7 +1324,10 @@ export class LineChart seriesName, // E.g. https://ourworldindata.org/grapher/size-poverty-gap-world label: !this.manager.showLegend ? "" : `${seriesName}`, - annotation: this.getAnnotationsForSeries(seriesName), + annotation: getAnnotationsForSeries( + this.annotationsMap, + seriesName + ), yValue: lastValue, } }) diff --git a/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts b/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts new file mode 100644 index 00000000000..74fdccc4c52 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/lineCharts/lineChartHelpers.ts @@ -0,0 +1,75 @@ +import { OwidTable } from "@ourworldindata/core-table" +import { + ColumnSlug, + EntityName, + PrimitiveType, + SeriesName, + SeriesStrategy, +} from "@ourworldindata/types" + +export type AnnotationsMap = Map> + +export function getSeriesName({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, +}: { + entityName: EntityName + columnName: string + seriesStrategy: SeriesStrategy + availableEntityNames: EntityName[] + canSelectMultipleEntities: boolean +}): SeriesName { + // if entities are plotted, use the entity name + if (seriesStrategy === SeriesStrategy.entity) return entityName + + // if columns are plotted, use the column name + // and prepend the entity name if multiple entities can be selected + return availableEntityNames.length > 1 || canSelectMultipleEntities + ? `${entityName} - ${columnName}` + : columnName +} + +export function getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, +}: { + entityName: EntityName + columnName: string + seriesStrategy: SeriesStrategy + availableEntityNames: EntityName[] +}): SeriesName { + // if entities are plotted, use the entity name + if (seriesStrategy === SeriesStrategy.entity) return entityName + + // If only one entity is plotted, we want to use the column colors. + // Unlike in `getSeriesName`, we don't care whether the user can select + // multiple entities, only whether more than one is plotted. + return availableEntityNames.length > 1 + ? `${entityName} - ${columnName}` + : columnName +} + +export function getAnnotationsMap( + table: OwidTable, + slug: ColumnSlug +): AnnotationsMap | undefined { + return table + .getAnnotationColumnForColumn(slug) + ?.getUniqueValuesGroupedBy(table.entityNameSlug) +} + +export function getAnnotationsForSeries( + annotationsMap: AnnotationsMap | undefined, + seriesName: SeriesName +): string | undefined { + const annotations = annotationsMap?.get(seriesName) + if (!annotations) return undefined + return Array.from(annotations.values()) + .filter((anno) => anno) + .join(" & ") +} diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx index f641a1b939b..a07da5438fa 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapChart.tsx @@ -346,12 +346,12 @@ export class MapChart return mapColumn.owidRows .map((row) => { - const { entityName, value, time } = row + const { entityName, value, originalTime } = row const color = this.colorScale.getColor(value) || "red" // todo: color fix if (!color) return undefined return { seriesName: entityName, - time, + time: originalTime, value, isSelected: selectionArray.selectedSet.has(entityName), color, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx index 7fa2a61dbfb..72977920cdb 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapSparkline.tsx @@ -118,7 +118,7 @@ export class MapSparkline extends React.Component<{ lineStrokeWidth: 2, entityYearHighlight: { entityName: this.manager.entityName, - year: this.manager.datum?.time, + year: this.manager.datum?.originalTime, }, yAxisConfig: { hideAxis: true, diff --git a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx index e58b90c5484..54aa0321aaa 100644 --- a/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx +++ b/packages/@ourworldindata/grapher/src/mapCharts/MapTooltip.tsx @@ -119,8 +119,8 @@ export class MapTooltip : targetTime?.toString() const displayDatumTime = timeColumn && datum - ? timeColumn.formatValue(datum?.time) - : (datum?.time.toString() ?? "") + ? timeColumn.formatValue(datum?.originalTime) + : datum?.originalTime.toString() ?? "" const valueColor: string | undefined = darkenColorForHighContrastText( lineColorScale?.getColor(datum?.value) ?? "#333" ) @@ -143,7 +143,7 @@ export class MapTooltip const yColumn = this.mapTable.get(this.mapColumnSlug) const targetNotice = - datum && datum.time !== targetTime ? displayTime : undefined + datum && datum.originalTime !== targetTime ? displayTime : undefined const toleranceNotice = targetNotice ? { icon: TooltipFooterIcon.notice, diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx index 6ccac9dd108..bf9dfb192f3 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/NoDataSection.tsx @@ -6,19 +6,19 @@ import { } from "../core/GrapherConstants" export function NoDataSection({ - entityNames, + seriesNames, bounds, baseFontSize = 16, }: { - entityNames: string[] + seriesNames: string[] bounds: Bounds baseFontSize?: number }): React.ReactElement { { - const displayedEntities = entityNames.slice(0, 5) - const numRemainingEntities = Math.max( + const displayedNames = seriesNames.slice(0, 5) + const remaining = Math.max( 0, - entityNames.length - displayedEntities.length + seriesNames.length - displayedNames.length ) return ( @@ -40,7 +40,7 @@ export function NoDataSection({ No data
    - {displayedEntities.map((entityName) => ( + {displayedNames.map((entityName) => (
  • ))}
- {numRemainingEntities > 0 && ( -
- &{" "} - {numRemainingEntities === 1 - ? "one" - : numRemainingEntities}{" "} - more -
+ {remaining > 0 && ( +
& {remaining === 1 ? "one" : remaining} more
)} ) diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx index e4688cad8ee..fc505bb6459 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterPlotChart.tsx @@ -871,7 +871,7 @@ export class ScatterPlotChart {!this.manager.isStatic && separatorLine(noDataSectionBounds.top)} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts index 7d7e4ae8d80..02a2bd75f00 100755 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.test.ts @@ -7,13 +7,13 @@ import { SynthesizeGDPTable, } from "@ourworldindata/core-table" import { ChartManager } from "../chart/ChartManager" -import { DEFAULT_SLOPE_CHART_COLOR } from "./SlopeChartConstants" -import { isNumber, OwidTableSlugs } from "@ourworldindata/utils" +import { isNumber } from "@ourworldindata/utils" const table = SynthesizeGDPTable({ timeRange: [2000, 2010] }) const manager: ChartManager = { table, yColumnSlug: SampleColumnSlugs.Population, + selection: table.availableEntityNames, } it("can create a new slope chart", () => { @@ -21,16 +21,6 @@ it("can create a new slope chart", () => { expect(chart.series.length).toEqual(2) }) -it("slope charts can have different colors", () => { - const manager: ChartManager = { - table, - yColumnSlug: SampleColumnSlugs.Population, - colorColumnSlug: OwidTableSlugs.entityName, - } - const chart = new SlopeChart({ manager }) - expect(chart.series[0].color).not.toEqual(DEFAULT_SLOPE_CHART_COLOR) -}) - it("filters non-numeric values", () => { const table = SynthesizeFruitTableWithStringValues( { @@ -48,10 +38,9 @@ it("filters non-numeric values", () => { const chart = new SlopeChart({ manager }) expect(chart.series.length).toEqual(1) expect( - chart.series.every((series) => - series.values.every( - (value) => isNumber(value.x) && isNumber(value.y) - ) + chart.series.every( + (series) => + isNumber(series.start.value) && isNumber(series.end.value) ) ).toBeTruthy() }) diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index 8b865b803d6..08604f5c25b 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -1,830 +1,457 @@ -import React from "react" +import React, { SVGProps } from "react" import { Bounds, DEFAULT_BOUNDS, - intersection, - without, - uniq, isEmpty, - last, - sortBy, - max, - getRelativeMouse, domainExtent, - minBy, exposeInstanceOnWindow, PointVector, clamp, - HorizontalAlign, - difference, makeIdForHumanConsumption, + guid, + excludeUndefined, + partition, + max, + getRelativeMouse, + minBy, } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { NoDataModal } from "../noDataModal/NoDataModal" -import { - VerticalColorLegend, - VerticalColorLegendManager, -} from "../verticalColorLegend/VerticalColorLegend" -import { ColorScale, ColorScaleManager } from "../color/ColorScale" import { BASE_FONT_SIZE, + GRAPHER_BACKGROUND_DEFAULT, GRAPHER_DARK_TEXT, - GRAPHER_FONT_SCALE_9_6, - GRAPHER_FONT_SCALE_10_5, + GRAPHER_FONT_SCALE_12, } from "../core/GrapherConstants" import { ScaleType, - EntitySelectionMode, - Color, SeriesName, ColorSchemeName, + ColumnSlug, + MissingDataStrategy, + Time, + SeriesStrategy, + EntityName, + RenderMode, } from "@ourworldindata/types" import { ChartInterface } from "../chart/ChartInterface" import { ChartManager } from "../chart/ChartManager" import { scaleLinear, ScaleLinear } from "d3-scale" import { select } from "d3-selection" import { - DEFAULT_SLOPE_CHART_COLOR, - LabelledSlopesProps, + PlacedSlopeChartSeries, + RawSlopeChartSeries, SlopeChartSeries, - SlopeChartValue, - SlopeEntryProps, } from "./SlopeChartConstants" -import { OwidTable } from "@ourworldindata/core-table" +import { CoreColumn, OwidTable } from "@ourworldindata/core-table" import { + autoDetectSeriesStrategy, autoDetectYColumnSlugs, + getDefaultFailMessage, makeSelectionArray, - isElementInteractive, } from "../chart/ChartUtils" -import { AxisConfig, AxisManager } from "../axis/AxisConfig" +import { AxisConfig } from "../axis/AxisConfig" import { VerticalAxis } from "../axis/Axis" import { VerticalAxisComponent } from "../axis/AxisViews" -import { - HorizontalCategoricalColorLegend, - HorizontalColorLegendManager, -} from "../horizontalColorLegend/HorizontalColorLegends" -import { CategoricalBin, ColorScaleBin } from "../color/ColorScaleBin" import { NoDataSection } from "../scatterCharts/NoDataSection" +import { CategoricalColorAssigner } from "../color/CategoricalColorAssigner" +import { ColorScheme } from "../color/ColorScheme" +import { ColorSchemes } from "../color/ColorSchemes" +import { LineLabelSeries, LineLegend } from "../lineLegend/LineLegend" +import { + makeTooltipRoundingNotice, + makeTooltipToleranceNotice, + Tooltip, + TooltipState, + TooltipValueRange, +} from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps" +import { + AnnotationsMap, + getAnnotationsForSeries, + getAnnotationsMap, + getColorKey, + getSeriesName, +} from "../lineCharts/lineChartHelpers" + +type SVGMouseOrTouchEvent = + | React.MouseEvent + | React.TouchEvent export interface SlopeChartManager extends ChartManager { - isModalOpen?: boolean + canSelectMultipleEntities?: boolean } -const LABEL_SLOPE_PADDING = 8 -const LABEL_LABEL_PADDING = 2 - const TOP_PADDING = 6 const BOTTOM_PADDING = 20 +const LINE_LEGEND_PADDING = 4 + @observer export class SlopeChart extends React.Component<{ bounds?: Bounds manager: SlopeChartManager }> - implements - ChartInterface, - VerticalColorLegendManager, - HorizontalColorLegendManager, - ColorScaleManager + implements ChartInterface { - // currently hovered individual series key - @observable hoverKey?: string - // currently hovered legend color - @observable hoverColor?: string + slopeAreaRef: React.RefObject = React.createRef() + defaultBaseColorScheme = ColorSchemeName.OwidDistinctLines - private hasInteractedWithChart = false + @observable hoveredSeriesName?: string + @observable tooltipState = new TooltipState<{ + series: SlopeChartSeries + }>({ fade: "immediate" }) transformTable(table: OwidTable) { - if (!table.has(this.yColumnSlug)) return table + table = table.filterByEntityNames( + this.selectionArray.selectedEntityNames + ) // TODO: remove this filter once we don't have mixed type columns in datasets - table = table.replaceNonNumericCellsWithErrorValues([this.yColumnSlug]) - - return table - .dropRowsWithErrorValuesForColumn(this.yColumnSlug) - .interpolateColumnWithTolerance(this.yColumnSlug) - } - - @computed get manager() { - return this.props.manager - } - - @computed.struct get bounds() { - return this.props.bounds ?? DEFAULT_BOUNDS - } - - @computed get isStatic(): boolean { - return this.manager.isStatic ?? false - } - - @computed get fontSize() { - return this.manager.fontSize ?? BASE_FONT_SIZE - } - - @computed private get isPortrait(): boolean { - return !!(this.manager.isNarrow || this.manager.isStaticAndSmall) - } - - @computed private get showHorizontalLegend(): boolean { - return !!(this.manager.isSemiNarrow || this.manager.isStaticAndSmall) - } + table = table.replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) - // used by the component - @computed get legendItems() { - return this.colorScale.legendBins - .filter((bin) => this.colorsInUse.includes(bin.color)) - .map((bin) => { - return { - key: bin.label ?? "", - label: bin.label ?? "", - color: bin.color, - } - }) - } - - // used by the component - @computed get categoricalLegendData(): CategoricalBin[] { - return this.legendItems.map( - (legendItem, index) => - new CategoricalBin({ - ...legendItem, - index, - value: legendItem.label, - }) - ) - } + if (this.isLogScale) + table = table.replaceNonPositiveCellsForLogScale(this.yColumnSlugs) - @action.bound onSlopeMouseOver(slopeProps: SlopeEntryProps) { - this.hoverKey = slopeProps.seriesName - } + this.yColumnSlugs.forEach((slug) => { + table = table.interpolateColumnWithTolerance(slug) + }) - @action.bound onSlopeMouseLeave() { - this.hoverKey = undefined + return table } - @action.bound onSlopeClick() { - const { hoverKey, isEntitySelectionEnabled } = this - if (!isEntitySelectionEnabled || hoverKey === undefined) { - return + transformTableForSelection(table: OwidTable): OwidTable { + // if entities with partial data are not plotted, + // make sure they don't show up in the entity selector + if (this.missingDataStrategy === MissingDataStrategy.hide) { + table = table + .replaceNonNumericCellsWithErrorValues(this.yColumnSlugs) + .dropEntitiesThatHaveNoDataInSomeColumn(this.yColumnSlugs) } - this.hasInteractedWithChart = true - this.selectionArray.toggleSelection(hoverKey) - } - - // Both legend managers accept a `onLegendMouseOver` property, but define different signatures. - // The component expects a string, - // the component expects a ColorScaleBin. - @action.bound onLegendMouseOver(binOrColor: string | ColorScaleBin) { - this.hoverColor = - typeof binOrColor === "string" ? binOrColor : binOrColor.color - } - - @action.bound onLegendMouseLeave() { - this.hoverColor = undefined - } - - @computed private get selectionArray() { - return makeSelectionArray(this.manager.selection) - } - @computed private get selectedEntityNames() { - return this.selectionArray.selectedEntityNames + return table } - @computed get isEntitySelectionEnabled(): boolean { - const { manager } = this - return !!( - manager.addCountryMode !== EntitySelectionMode.Disabled && - manager.addCountryMode + @computed get transformedTableFromGrapher(): OwidTable { + return ( + this.manager.transformedTable ?? + this.transformTable(this.inputTable) ) } - // When the color legend is clicked, toggle selection fo all associated keys - @action.bound onLegendClick() { - const { hoverColor, isEntitySelectionEnabled } = this - if (!isEntitySelectionEnabled || hoverColor === undefined) return - - this.hasInteractedWithChart = true - - const seriesNamesToToggle = this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - const areAllSeriesActive = - intersection(seriesNamesToToggle, this.selectedEntityNames) - .length === seriesNamesToToggle.length - if (areAllSeriesActive) - this.selectionArray.setSelectedEntities( - without(this.selectedEntityNames, ...seriesNamesToToggle) - ) - else - this.selectionArray.setSelectedEntities( - this.selectedEntityNames.concat(seriesNamesToToggle) + @computed get transformedTable(): OwidTable { + let table = this.transformedTableFromGrapher + // The % growth transform cannot be applied in transformTable() because it will filter out + // any rows before startHandleTimeBound and change the timeline bounds. + const { isRelativeMode, startHandleTimeBound } = this.manager + if (isRelativeMode && startHandleTimeBound !== undefined) { + table = table.toTotalGrowthForEachColumnComparedToStartTime( + startHandleTimeBound, + this.yColumnSlugs ?? [] ) + } + return table } - // Colors on the legend for which every matching group is focused - @computed get focusColors() { - const { colorsInUse } = this - return colorsInUse.filter((color) => { - const matchingSeriesNames = this.series - .filter((g) => g.color === color) - .map((g) => g.seriesName) - return ( - intersection(matchingSeriesNames, this.selectedEntityNames) - .length === matchingSeriesNames.length - ) - }) + @computed private get manager(): SlopeChartManager { + return this.props.manager } - @computed get focusKeys() { - return this.selectedEntityNames + @computed get inputTable(): OwidTable { + return this.manager.table } - // All currently hovered group keys, combining the legend and the main UI - @computed.struct get hoverKeys() { - const { hoverColor, hoverKey } = this - - const hoverKeys = - hoverColor === undefined - ? [] - : uniq( - this.series - .filter((g) => g.color === hoverColor) - .map((g) => g.seriesName) - ) - - if (hoverKey !== undefined) hoverKeys.push(hoverKey) - - return hoverKeys + @computed private get bounds(): Bounds { + return this.props.bounds ?? DEFAULT_BOUNDS } - // Colors currently on the chart and not greyed out - @computed get activeColors() { - const { hoverKeys, focusKeys } = this - const activeKeys = hoverKeys.concat(focusKeys) - - if (activeKeys.length === 0) - // No hover or focus means they're all active by default - return uniq(this.series.map((g) => g.color)) - - return uniq( - this.series - .filter((g) => activeKeys.indexOf(g.seriesName) !== -1) - .map((g) => g.color) - ) + @computed get fontSize() { + return this.manager.fontSize ?? BASE_FONT_SIZE } - // Only show colors on legend that are actually in use - @computed private get colorsInUse() { - return uniq(this.series.map((series) => series.color)) + @computed private get isLogScale(): boolean { + return this.yScaleType === ScaleType.log } - @computed get legendAlign(): HorizontalAlign { - return HorizontalAlign.left + @computed private get missingDataStrategy(): MissingDataStrategy { + return this.manager.missingDataStrategy || MissingDataStrategy.auto } - @computed get verticalColorLegend(): VerticalColorLegend { - return new VerticalColorLegend({ manager: this }) + @computed private get selectionArray() { + return makeSelectionArray(this.manager.selection) } - @computed get horizontalColorLegend(): HorizontalCategoricalColorLegend { - return new HorizontalCategoricalColorLegend({ manager: this }) + @computed private get formatColumn() { + return this.yColumns[0] } - @computed get legendHeight(): number { - return this.showHorizontalLegend - ? this.horizontalColorLegend.height - : this.verticalColorLegend.height + @computed private get sidebarWidth(): number { + return this.showNoDataSection + ? clamp(this.bounds.width * 0.125, 60, 140) + : 0 } - @computed get legendWidth(): number { - return this.showHorizontalLegend - ? this.bounds.width - : this.verticalColorLegend.width + // used by LineLegend + @computed get focusedSeriesNames(): SeriesName[] { + return this.hoveredSeriesName ? [this.hoveredSeriesName] : [] } - @computed get maxLegendWidth(): number { - return this.showHorizontalLegend - ? this.bounds.width - : this.bounds.width * 0.5 + @computed private get isFocusModeActive(): boolean { + return this.hoveredSeriesName !== undefined } - @computed private get sidebarWidth(): number { - // the min width is set to prevent the "No data" title from line breaking - return clamp(this.legendWidth, 51, this.maxLegendWidth) - } - - // correction is to account for the space taken by the legend - @computed private get innerBounds() { - const { sidebarWidth, showLegend, legendHeight } = this - let bounds = this.bounds - if (showLegend) { - bounds = this.showHorizontalLegend - ? bounds.padTop(legendHeight + 8) - : bounds.padRight(sidebarWidth + 16) - } - return bounds + @computed private get startX(): number { + return this.xScale(this.startTime) } - // verify the validity of data used to show legend - // this is for backwards compatibility with charts that were added without legend - // eg: https://ourworldindata.org/grapher/mortality-rate-improvement-by-cohort - @computed private get showLegend() { - const { colorsInUse } = this - const { legendBins } = this.colorScale - return legendBins.some((bin) => colorsInUse.includes(bin.color)) + @computed private get endX(): number { + return this.xScale(this.endTime) } - @computed - private get selectedEntitiesWithoutData(): string[] { - return difference( - this.selectedEntityNames, - this.series.map((s) => s.seriesName) - ) + private updateTooltipPosition(event: SVGMouseOrTouchEvent) { + const ref = this.manager.base?.current + if (ref) this.tooltipState.position = getRelativeMouse(ref, event) } - @computed private get noDataSection(): React.ReactElement { - const bounds = new Bounds( - this.legendX, - this.legendY + this.legendHeight + 12, - this.sidebarWidth, - this.bounds.height - this.legendHeight - 12 - ) - return ( - - ) - } + private detectHoveredSlope(event: SVGMouseOrTouchEvent) { + const ref = this.slopeAreaRef.current + if (!ref) return - render() { - if (this.failMessage) - return ( - - ) - - const { manager } = this.props - const { - series, - focusKeys, - hoverKeys, - innerBounds, - showLegend, - showHorizontalLegend, - selectedEntitiesWithoutData, - } = this - - const legend = showHorizontalLegend ? ( - - ) : ( - - ) - - return ( - - - {showLegend && legend} - {/* only show the "No data" section if there is space */} - {showLegend && - !showHorizontalLegend && - selectedEntitiesWithoutData.length > 0 && - this.noDataSection} - - ) - } + const mouse = getRelativeMouse(ref, event) + this.mouseFrame = requestAnimationFrame(() => { + if (this.placedSeries.length === 0) return - @computed get categoryLegendY(): number { - return this.bounds.top - } - - @computed get legendY() { - return this.bounds.top - } + const distanceMap = new Map() + for (const series of this.placedSeries) { + distanceMap.set( + series, + PointVector.distanceFromPointToLineSegmentSq( + mouse, + series.startPoint, + series.endPoint + ) + ) + } - @computed get legendX(): number { - return this.showHorizontalLegend - ? this.bounds.left - : this.bounds.right - this.sidebarWidth + const closestSlope = minBy(this.placedSeries, (s) => + distanceMap.get(s) + )! + const distanceSq = distanceMap.get(closestSlope)! + const tolerance = 10 + const toleranceSq = tolerance * tolerance + + if (closestSlope && distanceSq < toleranceSq) { + this.onSlopeMouseOver(closestSlope) + } else { + this.onSlopeMouseLeave() + } + }) } @computed get failMessage() { - if (this.yColumn.isMissing) return "Missing Y column" + const message = getDefaultFailMessage(this.manager) + if (message) return message + else if (this.startTime === this.endTime) return "No matching data" else if (isEmpty(this.series)) return "No matching data" return "" } - colorScale = this.props.manager.colorScaleOverride ?? new ColorScale(this) - - @computed get colorScaleConfig() { - return this.manager.colorScale - } - - @computed get colorScaleColumn() { - return ( - // For faceted charts, we have to get the values of inputTable before it's filtered by - // the faceting logic. - this.manager.colorScaleColumnOverride ?? this.colorColumn - ) - } - - defaultBaseColorScheme = ColorSchemeName.continents - - @computed private get yColumn() { - return this.transformedTable.get(this.yColumnSlug) - } - - @computed protected get yColumnSlug() { - return autoDetectYColumnSlugs(this.manager)[0] + @computed private get yColumns(): CoreColumn[] { + return this.yColumnSlugs.map((slug) => this.transformedTable.get(slug)) } - @computed private get colorColumn() { - // NB: This is tricky. Often it seems we use the Continent variable (123) for colors, but we only have 1 year for that variable, which - // would likely get filtered by any time filtering. So we need to jump up to the root table to get the color values we want. - // We should probably refactor this as part of a bigger color refactoring. - return this.inputTable.get(this.manager.colorColumnSlug) + @computed protected get yColumnSlugs(): ColumnSlug[] { + return autoDetectYColumnSlugs(this.manager) } - @computed get transformedTable() { + @computed private get colorScheme(): ColorScheme { return ( - this.manager.transformedTable ?? - this.transformTable(this.inputTable) + (this.manager.baseColorScheme + ? ColorSchemes.get(this.manager.baseColorScheme) + : null) ?? ColorSchemes.get(this.defaultBaseColorScheme) ) } - @computed get inputTable() { - return this.manager.table - } - - // helper method to directly get the associated color value given an Entity - // dimension data saves color a level deeper. eg: { Afghanistan => { 2015: Asia|Color }} - // this returns that data in the form { Afghanistan => Asia } - @computed private get colorBySeriesName(): Map< - SeriesName, - Color | undefined - > { - const { colorScale, colorColumn } = this - if (colorColumn.isMissing) return new Map() - - const colorByEntity = new Map() - - colorColumn.valueByEntityNameAndOriginalTime.forEach( - (timeToColorMap, seriesName) => { - const values = Array.from(timeToColorMap.values()) - const key = last(values) - colorByEntity.set(seriesName, colorScale.getColor(key)) - } - ) - - return colorByEntity + @computed private get startTime(): Time { + return this.transformedTable.minTime ?? 0 } - // click anywhere inside the Grapher frame to dismiss the current selection - @action.bound onGrapherClick(e: Event): void { - const target = e.target as HTMLElement - const isTargetInteractive = isElementInteractive(target) - if ( - this.isEntitySelectionEnabled && - this.hasInteractedWithChart && - !this.hoverKey && - !this.hoverColor && - !this.manager.isModalOpen && - !isTargetInteractive - ) { - this.selectionArray.clearSelection() - } + @computed private get endTime(): Time { + return this.transformedTable.maxTime ?? 0 } - @computed private get grapherElement() { - return this.manager.base?.current + @computed get seriesStrategy(): SeriesStrategy { + return autoDetectSeriesStrategy(this.manager, true) } - componentDidMount() { - if (this.grapherElement) { - // listening to "mousedown" instead of "click" fixes a bug - // where the current selection was incorrectly dismissed - // when the user drags the slider but releases the drag outside of the timeline - this.grapherElement.addEventListener( - "mousedown", - this.onGrapherClick - ) - } - exposeInstanceOnWindow(this) + @computed private get categoricalColorAssigner(): CategoricalColorAssigner { + return new CategoricalColorAssigner({ + colorScheme: this.colorScheme, + invertColorScheme: this.manager.invertColorScheme, + colorMap: + this.seriesStrategy === SeriesStrategy.entity + ? this.inputTable.entityNameColorIndex + : this.inputTable.columnDisplayNameToColorMap, + autoColorMapCache: this.manager.seriesColorMap, + }) } - componentWillUnmount(): void { - if (this.grapherElement) { - this.grapherElement.removeEventListener( - "mousedown", - this.onGrapherClick - ) - } + @computed private get annotationsMap(): AnnotationsMap | undefined { + return getAnnotationsMap(this.inputTable, this.yColumnSlugs[0]) } - @computed get series() { - const column = this.yColumn - if (!column) return [] - - const { colorBySeriesName } = this - const { minTime, maxTime } = column - - const table = this.inputTable - - return column.uniqEntityNames - .map((seriesName) => { - const values: SlopeChartValue[] = [] - - const yValues = - column.valueByEntityNameAndOriginalTime.get(seriesName)! || - [] - - yValues.forEach((value, time) => { - if (time !== minTime && time !== maxTime) return + private constructSingleSeries( + entityName: EntityName, + column: CoreColumn + ): RawSlopeChartSeries { + const { startTime, endTime, seriesStrategy } = this + const { canSelectMultipleEntities = false } = this.manager - values.push({ - x: time, - y: value, - }) - }) - - // sort values by time - const sortedValues = sortBy(values, (v) => v.x) - - const color = - table.getColorForEntityName(seriesName) ?? - colorBySeriesName.get(seriesName) ?? - DEFAULT_SLOPE_CHART_COLOR - - return { - seriesName, - color, - values: sortedValues, - } as SlopeChartSeries - }) - .filter((series) => series.values.length >= 2) - } -} - -@observer -class SlopeEntry extends React.Component { - line: SVGElement | null = null + const { availableEntityNames } = this.selectionArray + const columnName = column.nonEmptyDisplayName + const seriesName = getSeriesName({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + canSelectMultipleEntities, + }) - @computed get isInBackground() { - const { isLayerMode, isHovered, isFocused } = this.props + const owidRowByTime = column.owidRowByEntityNameAndTime.get(entityName) + const start = owidRowByTime?.get(startTime) + const end = owidRowByTime?.get(endTime) - if (!isLayerMode) return false + const colorKey = getColorKey({ + entityName, + columnName, + seriesStrategy, + availableEntityNames, + }) + const color = this.categoricalColorAssigner.assign(colorKey) - return !(isHovered || isFocused) - } + const annotation = getAnnotationsForSeries( + this.annotationsMap, + seriesName + ) - render() { - const { - x1, - y1, - x2, - y2, - color, - hasLeftLabel, - hasRightLabel, - leftValueLabel, - leftEntityLabel, - rightValueLabel, - rightEntityLabel, - leftEntityLabelBounds, - rightEntityLabelBounds, - isFocused, - isHovered, - isMultiHoverMode, + return { seriesName, - } = this.props - const { isInBackground } = this - - const lineColor = isInBackground ? "#e2e2e2" : color - const labelColor = isInBackground ? "#ccc" : GRAPHER_DARK_TEXT - const opacity = isHovered ? 1 : isFocused ? 0.7 : 0.5 - const lineStrokeWidth = - isHovered && !isMultiHoverMode ? 4 : isFocused ? 3 : 2 - - const showDots = isFocused || isHovered - const showValueLabels = isFocused || isHovered - const showLeftEntityLabel = isFocused || (isHovered && isMultiHoverMode) - - const sharedLabelProps = { - fill: labelColor, - style: { cursor: "default" }, + entityName, + color, + start, + end, + annotation, } + } + private isSeriesValid( + series: RawSlopeChartSeries + ): series is SlopeChartSeries { + const { start, end } = series return ( - - (this.line = el)} - x1={x1} - y1={y1} - x2={x2} - y2={y2} - stroke={lineColor} - strokeWidth={lineStrokeWidth} - opacity={opacity} - /> - {showDots && ( - <> - - - - )} - {/* value label on the left */} - {hasLeftLabel && - showValueLabels && - leftValueLabel.render( - x1 - LABEL_SLOPE_PADDING, - leftEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - textAnchor: "end", - }, - } - )} - {/* entity label on the left */} - {hasLeftLabel && - showLeftEntityLabel && - leftEntityLabel.render( - // -2px is a minor visual correction - leftEntityLabelBounds.x - 2, - leftEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - textAnchor: "end", - }, - } - )} - {/* value label on the right */} - {hasRightLabel && - showValueLabels && - rightValueLabel.render( - rightEntityLabelBounds.x + - rightEntityLabel.width + - LABEL_LABEL_PADDING, - rightEntityLabelBounds.y, - { - textProps: sharedLabelProps, - } - )} - {/* entity label on the right */} - {hasRightLabel && - rightEntityLabel.render( - rightEntityLabelBounds.x, - rightEntityLabelBounds.y, - { - textProps: { - ...sharedLabelProps, - fontWeight: - isFocused || isHovered ? "bold" : undefined, - }, - } - )} - + start?.value !== undefined && + end?.value !== undefined && + start.originalTime < end.originalTime ) } -} -@observer -class LabelledSlopes - extends React.Component - implements AxisManager -{ - base: React.RefObject = React.createRef() - - @computed private get data() { - return this.props.seriesArr - } + /** + * Usually we drop rows with missing data in the transformTable function. + * But slope charts have a "No data" section. If slopes that have data + * but shouldn't be plotted because a "sibling" slope of the same entity + * doesn't have data are dropped from the transformed table, then we + * would have no way of knowing whether a slope has been dropped because + * it actually had no data or a sibling slope had no data. That's why we + * filter out slopes that are valid but shouldn't be plotted here, so + * that the noDataSeries is populated correctly. + */ + private shouldSeriesBePlotted( + series: RawSlopeChartSeries + ): series is SlopeChartSeries { + if (!this.isSeriesValid(series)) return false - @computed private get yColumn() { - return this.props.yColumn - } + if ( + this.seriesStrategy === SeriesStrategy.column && + this.missingDataStrategy === MissingDataStrategy.hide + ) { + const entitySeries = this.rawSeriesByEntityName.get( + series.entityName + ) + return !!entitySeries?.every((series) => this.isSeriesValid(series)) + } - @computed private get manager() { - return this.props.manager + return true } - @computed private get bounds() { - return this.props.bounds + @computed private get rawSeries(): RawSlopeChartSeries[] { + return this.yColumns.flatMap((column) => + this.selectionArray.selectedEntityNames.map((entityName) => + this.constructSingleSeries(entityName, column) + ) + ) } - @computed get fontSize() { - return this.manager.fontSize ?? BASE_FONT_SIZE + @computed private get rawSeriesByEntityName(): Map< + SeriesName, + RawSlopeChartSeries[] + > { + const map = new Map() + this.rawSeries.forEach((series) => { + const { entityName } = series + if (!map.has(entityName)) map.set(entityName, []) + map.get(entityName)!.push(series) + }) + return map } - @computed private get focusedSeriesNames() { - return intersection( - this.props.focusKeys || [], - this.data.map((g) => g.seriesName) + @computed get series(): SlopeChartSeries[] { + return this.rawSeries.filter((series) => + this.shouldSeriesBePlotted(series) ) } - @computed private get hoveredSeriesNames() { - return intersection( - this.props.hoverKeys || [], - this.data.map((g) => g.seriesName) - ) - } + @computed private get placedSeries(): PlacedSlopeChartSeries[] { + const { yAxis, startX, endX } = this - // Layered mode occurs when any entity on the chart is hovered or focused - // Then, a special "foreground" set of entities is rendered over the background - @computed private get isLayerMode() { - return ( - this.hoveredSeriesNames.length > 0 || - this.focusedSeriesNames.length > 0 || - // if the user has selected entities that are not in the chart, - // we want to move all entities into the background - (this.props.focusKeys?.length > 0 && - this.focusedSeriesNames.length === 0) - ) - } + return this.series.map((series) => { + const startY = yAxis.place(series.start.value) + const endY = yAxis.place(series.end.value) - @computed private get isMultiHoverMode() { - return this.hoveredSeriesNames.length > 1 - } + const startPoint = new PointVector(startX, startY) + const endPoint = new PointVector(endX, endY) - @computed get isPortrait(): boolean { - return this.props.isPortrait + return { ...series, startPoint, endPoint } + }) } - @computed private get allValues() { - return this.props.seriesArr.flatMap((g) => g.values) + @computed + private get noDataSeries(): RawSlopeChartSeries[] { + return this.rawSeries.filter((series) => !this.isSeriesValid(series)) } - @computed private get xDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.x), - ScaleType.linear - ) + @computed private get showNoDataSection(): boolean { + return this.noDataSeries.length > 0 } @computed private get yAxisConfig(): AxisConfig { return new AxisConfig(this.manager.yAxisConfig, this) } - @computed get yAxis(): VerticalAxis { - const axis = this.yAxisConfig.toVerticalAxis() - axis.domain = this.yDomain - axis.range = this.yRange - axis.formatColumn = this.yColumn - axis.label = "" - return axis + @computed private get allValues(): number[] { + return this.series.flatMap((series) => [ + series.start.value, + series.end.value, + ]) } - @computed private get yScaleType() { - return this.yAxisConfig.scaleType || ScaleType.linear + @computed private get yScaleType(): ScaleType { + return this.yAxisConfig.scaleType ?? ScaleType.linear } @computed private get yDomainDefault(): [number, number] { - return domainExtent( - this.allValues.map((v) => v.y), - this.yScaleType || ScaleType.linear - ) - } - - @computed private get xDomain(): [number, number] { - return this.xDomainDefault + return domainExtent(this.allValues, this.yScaleType) } @computed private get yDomain(): [number, number] { @@ -843,30 +470,17 @@ class LabelledSlopes .yRange() } - @computed get yAxisWidth(): number { - return this.yAxis.width + 5 // 5px account for the tick marks + @computed get yAxis(): VerticalAxis { + const axis = this.yAxisConfig.toVerticalAxis() + axis.domain = this.yDomain + axis.range = this.yRange + axis.formatColumn = this.yColumns[0] + axis.label = "" + return axis } - @computed get xRange(): [number, number] { - // take into account the space taken by the yAxis and slope labels - const bounds = this.bounds - .padLeft(this.yAxisWidth + 4) - .padLeft(this.maxLabelWidth) - .padRight(this.maxLabelWidth) - - // pick a reasonable width based on an ideal aspect ratio - const idealAspectRatio = 0.9 - const availableWidth = bounds.width - const idealWidth = idealAspectRatio * bounds.height - const slopeWidth = this.isPortrait - ? availableWidth - : clamp(idealWidth, 220, availableWidth) - - const leftRightPadding = (availableWidth - slopeWidth) / 2 - return bounds - .padLeft(leftRightPadding) - .padRight(leftRightPadding) - .xRange() + @computed get yAxisWidth(): number { + return this.yAxis.width + 5 // 5px account for the tick marks } @computed private get xScale(): ScaleLinear { @@ -874,446 +488,554 @@ class LabelledSlopes return scaleLinear().domain(xDomain).range(xRange) } - @computed get maxLabelWidth(): number { - const { slopeLabels } = this - const maxLabelWidths = slopeLabels.map((slope) => { - const entityLabelWidth = slope.leftEntityLabel.width - const maxValueLabelWidth = Math.max( - slope.leftValueLabel.width, - slope.rightValueLabel.width - ) - return ( - entityLabelWidth + - maxValueLabelWidth + - LABEL_SLOPE_PADDING + - LABEL_LABEL_PADDING + @computed private get xDomain(): [number, number] { + return [this.startTime, this.endTime] + } + + @computed private get maxLabelWidth(): number { + // TODO: copied from line legend + const fontSize = + GRAPHER_FONT_SCALE_12 * (this.manager.fontSize ?? BASE_FONT_SIZE) + return max( + this.series.map( + (series) => + Bounds.forText(series.seriesName, { fontSize }).width ) - }) - return max(maxLabelWidths) ?? 0 + )! } - @computed get allowedLabelWidth() { - return this.bounds.width * 0.2 + @computed get maxLineLegendWidth(): number { + // todo: copied from line legend (left padding, marker margin) + return Math.min(this.maxLabelWidth + 35 + 4, this.bounds.width / 3) } - @computed private get slopeLabels() { - const { isPortrait, yColumn, allowedLabelWidth: maxWidth } = this + @computed get xRange(): [number, number] { + const lineLegendWidth = this.maxLineLegendWidth + LINE_LEGEND_PADDING + + // pick a reasonable max width based on an ideal aspect ratio + const idealAspectRatio = 0.6 + const chartAreaWidth = this.bounds.width - this.sidebarWidth + const availableWidth = + chartAreaWidth - this.yAxisWidth - lineLegendWidth + const idealWidth = idealAspectRatio * this.bounds.height + const maxSlopeWidth = Math.min(idealWidth, availableWidth) + + let startX = + this.bounds.x + Math.max(0.25 * chartAreaWidth, this.yAxisWidth + 4) + let endX = + this.bounds.x + + Math.min( + chartAreaWidth - 0.25 * chartAreaWidth, + chartAreaWidth - lineLegendWidth + ) - return this.data.map((series) => { - const text = series.seriesName - const [v1, v2] = series.values - const fontSize = - (isPortrait - ? GRAPHER_FONT_SCALE_9_6 - : GRAPHER_FONT_SCALE_10_5) * this.fontSize - const leftValueStr = yColumn.formatValueShort(v1.y) - const rightValueStr = yColumn.formatValueShort(v2.y) + const currentSlopeWidth = endX - startX + if (currentSlopeWidth > maxSlopeWidth) { + const padding = currentSlopeWidth - maxSlopeWidth + startX += padding / 2 + endX -= padding / 2 + } - // value labels - const valueLabelProps = { - maxWidth: Infinity, // no line break - fontSize, - lineHeight: 1, - } - const leftValueLabel = new TextWrap({ - text: leftValueStr, - ...valueLabelProps, - }) - const rightValueLabel = new TextWrap({ - text: rightValueStr, - ...valueLabelProps, - }) - - // entity labels - const entityLabelProps = { - ...valueLabelProps, - maxWidth, - fontWeight: 700, - separators: [" ", "-"], - } - const leftEntityLabel = new TextWrap({ - text, - ...entityLabelProps, - }) - const rightEntityLabel = new TextWrap({ - text, - ...entityLabelProps, - }) + return [startX, endX] + } + @computed get lineLegendX(): number { + return this.xRange[1] + LINE_LEGEND_PADDING + } + + // used in LineLegend + @computed get labelSeries(): LineLabelSeries[] { + return this.series.map((series) => { + const { seriesName, color, end, annotation } = series return { - seriesName: series.seriesName, - leftValueLabel, - leftEntityLabel, - rightValueLabel, - rightEntityLabel, + color, + seriesName, + label: seriesName, + annotation, + yValue: end.value, } }) } - @computed private get initialSlopeData() { - const { data, slopeLabels, xScale, yAxis, yDomain } = this + private playIntroAnimation() { + // Nice little intro animation + select(this.slopeAreaRef.current) + .select(".slopes") + .attr("stroke-dasharray", "100%") + .attr("stroke-dashoffset", "100%") + .transition() + .attr("stroke-dashoffset", "0%") + } - const slopeData: SlopeEntryProps[] = [] + componentDidMount() { + exposeInstanceOnWindow(this) - data.forEach((series, i) => { - // Ensure values fit inside the chart - if ( - !series.values.every( - (d) => d.y >= yDomain[0] && d.y <= yDomain[1] - ) - ) - return - - const labels = slopeLabels[i] - const [v1, v2] = series.values - const [x1, x2] = [xScale(v1.x), xScale(v2.x)] - const [y1, y2] = [yAxis.place(v1.y), yAxis.place(v2.y)] - - slopeData.push({ - ...labels, - x1, - y1, - x2, - y2, - color: series.color, - seriesName: series.seriesName, - isFocused: false, - isHovered: false, - hasLeftLabel: true, - hasRightLabel: true, - } as SlopeEntryProps) - }) + if (!this.manager.disableIntroAnimation) { + this.playIntroAnimation() + } + } - return slopeData + private hoverTimer?: NodeJS.Timeout + @action.bound onLineLegendMouseOver(seriesName: SeriesName): void { + clearTimeout(this.hoverTimer) + this.hoveredSeriesName = seriesName } - @computed get backgroundGroups() { - return this.slopeData.filter( - (group) => !(group.isHovered || group.isFocused) - ) + @action.bound onLineLegendMouseLeave(): void { + clearTimeout(this.hoverTimer) + this.hoverTimer = setTimeout(() => { + // wait before clearing selection in case the mouse is moving quickly over neighboring labels + this.hoveredSeriesName = undefined + }, 200) } - @computed get foregroundGroups() { - return this.slopeData.filter( - (group) => !!(group.isHovered || group.isFocused) - ) + @action.bound onSlopeMouseOver(series: SlopeChartSeries) { + this.hoveredSeriesName = series.seriesName + this.tooltipState.target = { series } } - // Get the final slope data with hover focusing and collision detection - @computed get slopeData(): SlopeEntryProps[] { - const { focusedSeriesNames, hoveredSeriesNames } = this + @action.bound onSlopeMouseLeave() { + this.hoveredSeriesName = undefined + this.tooltipState.target = null + } - let slopeData = this.initialSlopeData + mouseFrame?: number + @action.bound onMouseMove(event: SVGMouseOrTouchEvent) { + this.updateTooltipPosition(event) + this.detectHoveredSlope(event) + } - slopeData = slopeData.map((slope) => { - // used for collision detection - const leftEntityLabelBounds = new Bounds( - // labels on the left are placed like this: | - slope.x1 - - LABEL_SLOPE_PADDING - - slope.leftValueLabel.width - - LABEL_LABEL_PADDING, - slope.y1 - slope.leftEntityLabel.lines[0].height / 2, - slope.leftEntityLabel.width, - slope.leftEntityLabel.height - ) - const rightEntityLabelBounds = new Bounds( - // labels on the left are placed like this: | - slope.x2 + LABEL_SLOPE_PADDING, - slope.y2 - slope.rightEntityLabel.height / 2, - slope.rightEntityLabel.width, - slope.rightEntityLabel.height - ) + @action.bound onMouseLeave() { + if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) - // used to determine priority for labelling conflicts - const isFocused = focusedSeriesNames.includes(slope.seriesName) - const isHovered = hoveredSeriesNames.includes(slope.seriesName) + this.onSlopeMouseLeave() + } - return { - ...slope, - leftEntityLabelBounds, - rightEntityLabelBounds, - isFocused, - isHovered, - } - }) + @computed private get lineStrokeWidth(): number { + const factor = this.manager.isStaticAndSmall ? 2 : 1 + return factor * 2 + } - // How to work out which of two slopes to prioritize for labelling conflicts - function chooseLabel(s1: SlopeEntryProps, s2: SlopeEntryProps) { - if (s1.isHovered && !s2.isHovered) - // Hovered slopes always have priority - return s1 - else if (!s1.isHovered && s2.isHovered) return s2 - else if (s1.isFocused && !s2.isFocused) - // Focused slopes are next in priority - return s1 - else if (!s1.isFocused && s2.isFocused) return s2 - else if (s1.hasRightLabel && !s2.hasRightLabel) - // Slopes which already have one label are prioritized for the other side - return s1 - else if (!s1.hasRightLabel && s2.hasRightLabel) return s2 - else return s1 // Equal priority, just do the first one - } + @computed private get backgroundColor(): string { + return this.manager.backgroundColor ?? GRAPHER_BACKGROUND_DEFAULT + } - // Eliminate overlapping labels, one pass for each side - slopeData.forEach((s1) => { - slopeData.forEach((s2) => { - if ( - s1 !== s2 && - s1.hasRightLabel && - s2.hasRightLabel && - // entity labels don't necessarily share the same x position. - // that's why we check for vertical intersection only - s1.rightEntityLabelBounds.hasVerticalOverlap( - s2.rightEntityLabelBounds - ) - ) { - if (chooseLabel(s1, s2) === s1) s2.hasRightLabel = false - else s1.hasRightLabel = false - } - }) - }) + @computed get renderUid(): number { + return guid() + } - slopeData.forEach((s1) => { - slopeData.forEach((s2) => { - if ( - s1 !== s2 && - s1.hasLeftLabel && - s2.hasLeftLabel && - // entity labels don't necessarily share the same x position. - // that's why we check for vertical intersection only - s1.leftEntityLabelBounds.hasVerticalOverlap( - s2.leftEntityLabelBounds - ) - ) { - if (chooseLabel(s1, s2) === s1) s2.hasLeftLabel = false - else s1.hasLeftLabel = false - } - }) - }) + @computed get tooltip(): React.ReactElement | undefined { + const { + tooltipState: { target, position, fading }, + startTime, + endTime, + } = this - // Order by focus/hover for draw order - slopeData = sortBy(slopeData, (slope) => - slope.isFocused || slope.isHovered ? 1 : 0 - ) + const { series } = target || {} + if (!series) return - return slopeData - } + const formatTime = (time: Time) => this.formatColumn.formatTime(time) - mouseFrame?: number - @action.bound onMouseLeave() { - if (this.mouseFrame !== undefined) cancelAnimationFrame(this.mouseFrame) + const isStartValueOriginal = series.start.originalTime === startTime + const isEndValueOriginal = series.end.originalTime === endTime + const actualStartTime = isStartValueOriginal + ? startTime + : series.start.originalTime + const actualEndTime = isEndValueOriginal + ? endTime + : series.end.originalTime - if (this.props.onMouseLeave) this.props.onMouseLeave() - } - - @action.bound onMouseMove( - ev: React.MouseEvent | React.TouchEvent - ) { - if (this.base.current) { - const mouse = getRelativeMouse(this.base.current, ev.nativeEvent) - - this.mouseFrame = requestAnimationFrame(() => { - if (this.props.bounds.contains(mouse)) { - if (this.slopeData.length === 0) return - - const { x1: startX, x2: endX } = this.slopeData[0] - - // whether the mouse is over the chart area, - // the left label area, or the right label area - const mousePosition = - mouse.x < startX - ? "left" - : mouse.x > endX - ? "right" - : "chart" - - // don't track mouse movements when hovering over labels on the left - if (mousePosition === "left") { - this.props.onMouseLeave() - return - } - - const distToSlopeOrLabel = new Map< - SlopeEntryProps, - number - >() - for (const s of this.slopeData) { - // start and end point of a line - let p1: PointVector - let p2: PointVector - - if (mousePosition === "chart") { - // points define the slope line - p1 = new PointVector(s.x1, s.y1) - p2 = new PointVector(s.x2, s.y2) - } else { - const labelBox = s.rightEntityLabelBounds.toProps() - // points define a "strike-through" line that stretches from - // the end point of the slopes to the right side of the right label - const y = labelBox.y + labelBox.height / 2 - p1 = new PointVector(endX, y) - p2 = new PointVector(labelBox.x + labelBox.width, y) - } - - // calculate the distance to the slope or label - const dist = - PointVector.distanceFromPointToLineSegmentSq( - mouse, - p1, - p2 - ) - distToSlopeOrLabel.set(s, dist) - } - - const closestSlope = minBy(this.slopeData, (s) => - distToSlopeOrLabel.get(s) - ) - const distanceSq = distToSlopeOrLabel.get(closestSlope!)! - const tolerance = mousePosition === "chart" ? 20 : 10 - const toleranceSq = tolerance * tolerance - - if ( - closestSlope && - distanceSq < toleranceSq && - this.props.onMouseOver - ) { - this.props.onMouseOver(closestSlope) - } else { - this.props.onMouseLeave() - } - } - }) - } - } + const { isRelativeMode } = this.manager, + timeRange = `${formatTime(actualStartTime)} to ${formatTime(actualEndTime)}`, + timeLabel = timeRange + (isRelativeMode ? " (relative change)" : "") - @action.bound onClick() { - if (this.props.onClick) this.props.onClick() - } + const columns = this.yColumns + const allRoundedToSigFigs = columns.every( + (column) => column.roundsToSignificantFigures + ) + const anyRoundedToSigFigs = columns.some( + (column) => column.roundsToSignificantFigures + ) + const sigFigs = excludeUndefined( + columns.map((column) => + column.roundsToSignificantFigures + ? column.numSignificantFigures + : undefined + ) + ) - componentDidMount() { - if (!this.manager.disableIntroAnimation) { - this.playIntroAnimation() + const constructTargetYearForToleranceNotice = () => { + if (!isStartValueOriginal && !isEndValueOriginal) { + return `${formatTime(startTime)} and ${formatTime(endTime)}` + } else if (!isStartValueOriginal) { + return formatTime(startTime) + } else if (!isEndValueOriginal) { + return formatTime(endTime) + } else { + return undefined + } } - } - private playIntroAnimation() { - // Nice little intro animation - select(this.base.current) - .select(".slopes") - .attr("stroke-dasharray", "100%") - .attr("stroke-dashoffset", "100%") - .transition() - .attr("stroke-dashoffset", "0%") + const targetYear = constructTargetYearForToleranceNotice() + const toleranceNotice = targetYear + ? { + icon: TooltipFooterIcon.notice, + text: makeTooltipToleranceNotice(targetYear), + } + : undefined + const roundingNotice = anyRoundedToSigFigs + ? { + icon: allRoundedToSigFigs + ? TooltipFooterIcon.none + : TooltipFooterIcon.significance, + text: makeTooltipRoundingNotice(sigFigs, { + plural: sigFigs.length > 1, + }), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) + + return ( + (this.tooltipState.target = null)} + > + + + ) } - renderGroups(groups: SlopeEntryProps[]) { - const { isLayerMode, isMultiHoverMode } = this + private renderNoDataSection(): React.ReactElement { + const seriesNames = this.noDataSeries.map((series) => series.seriesName) + const bounds = new Bounds( + this.bounds.right - this.sidebarWidth, + this.bounds.top, + this.sidebarWidth, + this.bounds.height + ) - return groups.map((slope) => ( - - )) + ) } - render() { - const { bounds, slopeData, xDomain, yAxis, yRange, onMouseMove } = this + private renderSlope( + series: PlacedSlopeChartSeries, + mode?: RenderMode + ): React.ReactElement { + return ( + + ) + } - if (isEmpty(slopeData)) - return + private renderSlopes() { + if (!this.isFocusModeActive) { + return this.placedSeries.map((series) => this.renderSlope(series)) + } - const { x1, x2 } = slopeData[0] - const [y1, y2] = yRange + const [focusedSeries, backgroundSeries] = partition( + this.placedSeries, + (series) => series.seriesName === this.hoveredSeriesName + ) return ( - + <> + {backgroundSeries.map((series) => + this.renderSlope(series, RenderMode.mute) + )} + {focusedSeries.map((series) => + this.renderSlope(series, RenderMode.focus) + )} + + ) + } + + private renderChartArea() { + const { bounds, xDomain, yRange, startX, endX } = this + + const [bottom, top] = yRange + + return ( + + - - {this.yAxis.tickLabels.map((tick) => { - const y = yAxis.place(tick.value) - return ( - - {/* grid lines connecting the chart area to the axis */} - - {/* grid lines within the chart area */} - - - ) - })} - - - - - {this.yColumn.formatTime(xDomain[0])} - - + + - {this.yColumn.formatTime(xDomain[1])} - - - {this.renderGroups(this.backgroundGroups)} - {this.renderGroups(this.foregroundGroups)} + + {this.renderSlopes()} ) } + + render() { + if (this.failMessage) + return ( + + ) + + return ( + + {this.renderChartArea()} + {this.manager.showLegend && } + {this.showNoDataSection && this.renderNoDataSection()} + {this.tooltip} + + ) + } +} + +interface SlopeProps { + series: PlacedSlopeChartSeries + color: string + mode?: RenderMode + dotRadius?: number + strokeWidth?: number + outlineWidth?: number + outlineStroke?: string + onMouseOver?: (series: SlopeChartSeries) => void + onMouseLeave?: () => void +} + +function Slope({ + series, + color, + mode = RenderMode.default, + dotRadius = 3.5, + strokeWidth = 2, + outlineWidth = 0.5, + outlineStroke = "#fff", + onMouseOver, + onMouseLeave, +}: SlopeProps) { + const { seriesName, startPoint, endPoint } = series + + const opacity = { + [RenderMode.default]: 1, + [RenderMode.focus]: 1, + [RenderMode.mute]: 0.3, + [RenderMode.background]: 0.3, + }[mode] + + return ( + onMouseOver?.(series)} + onMouseLeave={() => onMouseLeave?.()} + > + + + + + ) +} + +interface HaloLineProps extends SVGProps { + startPoint: PointVector + endPoint: PointVector + strokeWidth?: number + outlineWidth?: number + outlineStroke?: string +} + +function HaloLine(props: HaloLineProps): React.ReactElement { + const { + startPoint, + endPoint, + outlineWidth = 0.5, + outlineStroke = "#fff", + ...styleProps + } = props + return ( + <> + + + + ) +} + +interface GridLinesProps { + bounds: Bounds + yAxis: VerticalAxis + startX: number + endX: number +} + +function GridLines({ bounds, yAxis, startX, endX }: GridLinesProps) { + return ( + + {yAxis.tickLabels.map((tick) => { + const y = yAxis.place(tick.value) + return ( + + {/* grid lines connecting the chart area to the axis */} + + {/* grid lines within the chart area */} + + + ) + })} + + ) +} + +function MarkX({ + label, + x, + top, + bottom, + fontSize, +}: { + label: string + x: number + top: number + bottom: number + fontSize: number +}) { + return ( + <> + + + {label} + + + ) } diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts index bb52f727212..321ac2e6932 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChartConstants.ts @@ -1,62 +1,19 @@ -import { CoreColumn } from "@ourworldindata/core-table" +import { PartialBy, PointVector } from "@ourworldindata/utils" +import { EntityName, OwidVariableRow } from "@ourworldindata/types" import { ChartSeries } from "../chart/ChartInterface" -import { ChartManager } from "../chart/ChartManager" -import { ScaleType } from "@ourworldindata/types" -import { Bounds } from "@ourworldindata/utils" -import { TextWrap } from "@ourworldindata/components" - -export interface SlopeChartValue { - x: number - y: number -} export interface SlopeChartSeries extends ChartSeries { - size: number - values: SlopeChartValue[] + entityName: EntityName + start: Pick, "value" | "originalTime"> + end: Pick, "value" | "originalTime"> + annotation?: string } -export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" - -export interface SlopeEntryProps extends ChartSeries { - isLayerMode: boolean - isMultiHoverMode: boolean - x1: number - y1: number - x2: number - y2: number - - hasLeftLabel: boolean - leftEntityLabel: TextWrap - leftValueLabel: TextWrap - leftEntityLabelBounds: Bounds +export type RawSlopeChartSeries = PartialBy - hasRightLabel: boolean - rightEntityLabel: TextWrap - rightEntityLabelBounds: Bounds - rightValueLabel: TextWrap - - isFocused: boolean - isHovered: boolean -} - -export interface LabelledSlopesProps { - manager: ChartManager - yColumn: CoreColumn - bounds: Bounds - seriesArr: SlopeChartSeries[] - focusKeys: string[] - hoverKeys: string[] - onMouseOver: (slopeProps: SlopeEntryProps) => void - onMouseLeave: () => void - onClick: () => void - isPortrait: boolean +export interface PlacedSlopeChartSeries extends SlopeChartSeries { + startPoint: PointVector + endPoint: PointVector } -export interface SlopeAxisProps { - bounds: Bounds - orient: "left" | "right" - column: CoreColumn - scale: any - scaleType: ScaleType - fontSize: number -} +export const DEFAULT_SLOPE_CHART_COLOR = "#ff7f0e" diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx index 9004f813505..172d7e0bf9c 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/AbstractStackedChart.tsx @@ -405,8 +405,8 @@ export class AbstractStackedChart const pointColor = row.value > 0 ? POSITIVE_COLOR : NEGATIVE_COLOR return { - position: row.time, - time: row.time, + position: row.originalTime, + time: row.originalTime, value: row.value, valueOffset: 0, interpolated: diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx index 9fa7f41afd6..e89b176b4da 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/MarimekkoChart.tsx @@ -383,7 +383,7 @@ export class MarimekkoChart col.def.color ?? colorScheme.getColors(yColumns.length)[i], points: col.owidRows.map((row) => ({ - time: row.time, + time: row.originalTime, position: row.entityName, value: row.value, valueOffset: 0, @@ -417,7 +417,7 @@ export class MarimekkoChart const points: SimplePoint[] = [] for (const row of rows) { points.push({ - time: row.time, + time: row.originalTime, value: row.value, entity: row.entityName, }) diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 857a08a1b4a..96bb76f8625 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -18,7 +18,7 @@ import { max, } from "@ourworldindata/utils" import { computed, action, observable } from "mobx" -import { SeriesName } from "@ourworldindata/types" +import { RenderMode, SeriesName } from "@ourworldindata/types" import { GRAPHER_AREA_OPACITY_DEFAULT, GRAPHER_AREA_OPACITY_MUTE, @@ -68,21 +68,21 @@ interface AreasProps extends React.SVGAttributes { const STACKED_AREA_CHART_CLASS_NAME = "StackedArea" -const AREA_OPACITY = { - DEFAULT: GRAPHER_AREA_OPACITY_DEFAULT, - FOCUS: GRAPHER_AREA_OPACITY_FOCUS, - MUTE: GRAPHER_AREA_OPACITY_MUTE, +const AREA_OPACITY: Partial> = { + default: GRAPHER_AREA_OPACITY_DEFAULT, + focus: GRAPHER_AREA_OPACITY_FOCUS, + mute: GRAPHER_AREA_OPACITY_MUTE, } -const BORDER_OPACITY = { - DEFAULT: 0.7, - HOVER: 1, - MUTE: 0.3, +const BORDER_OPACITY: Partial> = { + default: 0.7, + focus: 1, + mute: 0.3, } -const BORDER_WIDTH = { - DEFAULT: 0.5, - HOVER: 1.5, +const BORDER_WIDTH: Partial> = { + default: 0.5, + mute: 1.5, } @observer @@ -183,10 +183,10 @@ class Areas extends React.Component { } const points = [...placedPoints, ...reverse(clone(prevPoints))] const opacity = !this.isFocusModeActive - ? AREA_OPACITY.DEFAULT // normal opacity + ? AREA_OPACITY.default // normal opacity : focusedSeriesName === series.seriesName - ? AREA_OPACITY.FOCUS // hovered - : AREA_OPACITY.MUTE // non-hovered + ? AREA_OPACITY.focus // hovered + : AREA_OPACITY.mute // non-hovered return ( { return placedSeriesArr.map((placedSeries) => { const opacity = !this.isFocusModeActive - ? BORDER_OPACITY.DEFAULT // normal opacity + ? BORDER_OPACITY.default // normal opacity : focusedSeriesName === placedSeries.seriesName - ? BORDER_OPACITY.HOVER // hovered - : BORDER_OPACITY.MUTE // non-hovered + ? BORDER_OPACITY.focus // hovered + : BORDER_OPACITY.mute // non-hovered const strokeWidth = focusedSeriesName === placedSeries.seriesName - ? BORDER_WIDTH.HOVER - : BORDER_WIDTH.DEFAULT + ? BORDER_WIDTH.focus + : BORDER_WIDTH.default return ( ({ - time: row.time, + time: row.originalTime, position: row.entityName, value: row.value, valueOffset: 0, diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx index 3078fd31d53..a3a8b7f0f1d 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx @@ -337,8 +337,12 @@ export function IconCircledS({ ) } -export function makeTooltipToleranceNotice(targetYear: string): string { - return `Data not available for ${targetYear}. Showing closest available data point instead` +export function makeTooltipToleranceNotice( + targetYear: string, + { plural }: { plural: boolean } = { plural: false } +): string { + const dataPoint = plural ? "data points" : "data point" + return `Data not available for ${targetYear}. Showing closest available ${dataPoint} instead` } export function makeTooltipRoundingNotice( diff --git a/packages/@ourworldindata/types/src/domainTypes/CoreTableTypes.ts b/packages/@ourworldindata/types/src/domainTypes/CoreTableTypes.ts index 5fa5ba92b00..a44d6d42459 100644 --- a/packages/@ourworldindata/types/src/domainTypes/CoreTableTypes.ts +++ b/packages/@ourworldindata/types/src/domainTypes/CoreTableTypes.ts @@ -300,5 +300,6 @@ export interface OwidVariableRow { entityName: EntityName time: Time value: ValueType + originalTime: Time originalValue?: ValueType } diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 1d73e6a6470..8306197da2f 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -208,6 +208,13 @@ export interface AnnotationFieldsInTitle { changeInPrefix?: boolean } +export enum RenderMode { + default = "default", + focus = "focus", // hovered or focused + mute = "mute", // not hovered + background = "background", // not focused +} + export interface Tickmark { value: number priority: number diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index 562dd93191d..0f42e50f1ad 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -113,6 +113,7 @@ export { GrapherWindowType, AxisMinMaxValueStr, GrapherTooltipAnchor, + RenderMode, } from "./grapherTypes/GrapherTypes.js" export { diff --git a/vite.config-site.mts.timestamp-1732528761102-d1ef0b3f0da3.mjs b/vite.config-site.mts.timestamp-1732528761102-d1ef0b3f0da3.mjs new file mode 100644 index 00000000000..1ff6001c7dc --- /dev/null +++ b/vite.config-site.mts.timestamp-1732528761102-d1ef0b3f0da3.mjs @@ -0,0 +1,358 @@ +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// site/viteUtils.tsx +import React from "file:///Users/sophia/code/owid/owid-grapher/node_modules/react/index.js"; + +// settings/findBaseDir.ts +import path from "path"; +import fs from "fs"; +function findProjectBaseDir(from) { + if (!fs.existsSync) return void 0; + let dir = path.dirname(from); + while (dir.length) { + if (fs.existsSync(path.resolve(dir, "package.json"))) return dir; + const parentDir = path.resolve(dir, ".."); + if (parentDir === dir) break; + else dir = parentDir; + } + return void 0; +} + +// site/viteUtils.tsx +import fs3 from "file:///Users/sophia/code/owid/owid-grapher/node_modules/fs-extra/lib/index.js"; + +// settings/serverSettings.ts +import path2 from "path"; +import dotenv2 from "file:///Users/sophia/code/owid/owid-grapher/node_modules/dotenv/lib/main.js"; +import fs2 from "fs"; +import ini from "file:///Users/sophia/code/owid/owid-grapher/node_modules/ini/lib/ini.js"; +import os from "os"; + +// settings/clientSettings.ts +var clientSettings_exports = {}; +__export(clientSettings_exports, { + ADMIN_BASE_URL: () => ADMIN_BASE_URL, + ADMIN_SERVER_HOST: () => ADMIN_SERVER_HOST, + ADMIN_SERVER_PORT: () => ADMIN_SERVER_PORT, + ALGOLIA_ID: () => ALGOLIA_ID, + ALGOLIA_INDEX_PREFIX: () => ALGOLIA_INDEX_PREFIX, + ALGOLIA_SEARCH_KEY: () => ALGOLIA_SEARCH_KEY, + BAKED_BASE_URL: () => BAKED_BASE_URL, + BAKED_GRAPHER_EXPORTS_BASE_URL: () => BAKED_GRAPHER_EXPORTS_BASE_URL, + BAKED_GRAPHER_URL: () => BAKED_GRAPHER_URL, + BAKED_SITE_EXPORTS_BASE_URL: () => BAKED_SITE_EXPORTS_BASE_URL, + BUGSNAG_API_KEY: () => BUGSNAG_API_KEY, + DATA_API_URL: () => DATA_API_URL, + DONATE_API_URL: () => DONATE_API_URL, + ENV: () => ENV, + ETL_API_URL: () => ETL_API_URL, + ETL_WIZARD_URL: () => ETL_WIZARD_URL, + EXPLORER_DYNAMIC_THUMBNAIL_URL: () => EXPLORER_DYNAMIC_THUMBNAIL_URL, + FEATURE_FLAGS: () => FEATURE_FLAGS, + FeatureFlagFeature: () => FeatureFlagFeature, + GDOCS_BASIC_ARTICLE_TEMPLATE_URL: () => GDOCS_BASIC_ARTICLE_TEMPLATE_URL, + GDOCS_CLIENT_EMAIL: () => GDOCS_CLIENT_EMAIL, + GDOCS_DETAILS_ON_DEMAND_ID: () => GDOCS_DETAILS_ON_DEMAND_ID, + GOOGLE_TAG_MANAGER_ID: () => GOOGLE_TAG_MANAGER_ID, + GRAPHER_DYNAMIC_CONFIG_URL: () => GRAPHER_DYNAMIC_CONFIG_URL, + GRAPHER_DYNAMIC_THUMBNAIL_URL: () => GRAPHER_DYNAMIC_THUMBNAIL_URL, + IMAGE_HOSTING_R2_BUCKET_PATH: () => IMAGE_HOSTING_R2_BUCKET_PATH, + IMAGE_HOSTING_R2_BUCKET_SUBFOLDER_PATH: () => IMAGE_HOSTING_R2_BUCKET_SUBFOLDER_PATH, + IMAGE_HOSTING_R2_CDN_URL: () => IMAGE_HOSTING_R2_CDN_URL, + MULTI_DIM_DYNAMIC_CONFIG_URL: () => MULTI_DIM_DYNAMIC_CONFIG_URL, + PUBLISHED_AT_FORMAT: () => PUBLISHED_AT_FORMAT, + RECAPTCHA_SITE_KEY: () => RECAPTCHA_SITE_KEY, + SENTRY_DSN: () => SENTRY_DSN, + TOPICS_CONTENT_GRAPH: () => TOPICS_CONTENT_GRAPH +}); +import dotenv from "file:///Users/sophia/code/owid/owid-grapher/node_modules/dotenv/lib/main.js"; +import { parseIntOrUndefined } from "file:///Users/sophia/code/owid/owid-grapher/packages/@ourworldindata/utils/dist/index.js"; +var __vite_injected_original_dirname2 = "/Users/sophia/code/owid/owid-grapher/settings"; +if (typeof __vite_injected_original_dirname2 !== "undefined") { + const baseDir2 = findProjectBaseDir(__vite_injected_original_dirname2); + if (baseDir2) dotenv.config({ path: `${baseDir2}/.env` }); +} +var ENV = process.env.ENV === "production" ? "production" : "development"; +var BUGSNAG_API_KEY = process.env.BUGSNAG_API_KEY; +var SENTRY_DSN = process.env.SENTRY_DSN; +var ADMIN_SERVER_PORT = parseIntOrUndefined(process.env.ADMIN_SERVER_PORT) ?? 3030; +var ADMIN_SERVER_HOST = process.env.ADMIN_SERVER_HOST ?? "localhost"; +var BAKED_BASE_URL = process.env.BAKED_BASE_URL ?? `http://${ADMIN_SERVER_HOST}:${ADMIN_SERVER_PORT}`; +var BAKED_GRAPHER_URL = process.env.BAKED_GRAPHER_URL ?? `${BAKED_BASE_URL}/grapher`; +var BAKED_GRAPHER_EXPORTS_BASE_URL = process.env.BAKED_GRAPHER_EXPORTS_BASE_URL ?? `${BAKED_GRAPHER_URL}/exports`; +var BAKED_SITE_EXPORTS_BASE_URL = process.env.BAKED_SITE_EXPORTS_BASE_URL ?? `${BAKED_BASE_URL}/exports`; +var GRAPHER_DYNAMIC_THUMBNAIL_URL = process.env.GRAPHER_DYNAMIC_THUMBNAIL_URL ?? `${BAKED_GRAPHER_URL}`; +var EXPLORER_DYNAMIC_THUMBNAIL_URL = process.env.EXPLORER_DYNAMIC_THUMBNAIL_URL ?? `${BAKED_BASE_URL}/explorers`; +var GRAPHER_DYNAMIC_CONFIG_URL = process.env.GRAPHER_DYNAMIC_CONFIG_URL ?? `${BAKED_GRAPHER_URL}`; +var MULTI_DIM_DYNAMIC_CONFIG_URL = process.env.MULTI_DIM_DYNAMIC_CONFIG_URL ?? `${BAKED_BASE_URL}/multi-dim`; +var ADMIN_BASE_URL = process.env.ADMIN_BASE_URL ?? `http://${ADMIN_SERVER_HOST}:${ADMIN_SERVER_PORT}`; +var DATA_API_URL = process.env.DATA_API_URL ?? "https://api.ourworldindata.org/v1/indicators/"; +var ALGOLIA_ID = process.env.ALGOLIA_ID ?? ""; +var ALGOLIA_SEARCH_KEY = process.env.ALGOLIA_SEARCH_KEY ?? ""; +var ALGOLIA_INDEX_PREFIX = process.env.ALGOLIA_INDEX_PREFIX ?? ""; +var DONATE_API_URL = process.env.DONATE_API_URL ?? "http://localhost:8788/donation/donate"; +var RECAPTCHA_SITE_KEY = process.env.RECAPTCHA_SITE_KEY ?? "6LcJl5YUAAAAAATQ6F4vl9dAWRZeKPBm15MAZj4Q"; +var GOOGLE_TAG_MANAGER_ID = process.env.GOOGLE_TAG_MANAGER_ID ?? ""; +var TOPICS_CONTENT_GRAPH = process.env.TOPICS_CONTENT_GRAPH === "true"; +var GDOCS_CLIENT_EMAIL = process.env.GDOCS_CLIENT_EMAIL ?? ""; +var GDOCS_BASIC_ARTICLE_TEMPLATE_URL = process.env.GDOCS_BASIC_ARTICLE_TEMPLATE_URL ?? ""; +var IMAGE_HOSTING_R2_CDN_URL = process.env.IMAGE_HOSTING_R2_CDN_URL || ""; +var IMAGE_HOSTING_R2_BUCKET_PATH = process.env.IMAGE_HOSTING_R2_BUCKET_PATH || ""; +var IMAGE_HOSTING_R2_BUCKET_SUBFOLDER_PATH = IMAGE_HOSTING_R2_BUCKET_PATH.slice( + IMAGE_HOSTING_R2_BUCKET_PATH.indexOf("/") + 1 +); +var ETL_WIZARD_URL = process.env.ETL_WIZARD_URL ?? `http://${ADMIN_SERVER_HOST}:8053/`; +var ETL_API_URL = process.env.ETL_API_URL ?? `http://${ADMIN_SERVER_HOST}:8081/api/v1`; +var GDOCS_DETAILS_ON_DEMAND_ID = process.env.GDOCS_DETAILS_ON_DEMAND_ID ?? ""; +var PUBLISHED_AT_FORMAT = "ddd, MMM D, YYYY HH:mm"; +var FeatureFlagFeature = /* @__PURE__ */ ((FeatureFlagFeature2) => { + FeatureFlagFeature2["MultiDimDataPage"] = "MultiDimDataPage"; + return FeatureFlagFeature2; +})(FeatureFlagFeature || {}); +var featureFlagsRaw = typeof process.env.FEATURE_FLAGS === "string" && process.env.FEATURE_FLAGS.trim()?.split(",") || []; +var FEATURE_FLAGS = new Set( + Object.keys(FeatureFlagFeature).filter( + (key) => featureFlagsRaw.includes(key) + ) +); + +// settings/serverSettings.ts +import { parseIntOrUndefined as parseIntOrUndefined2 } from "file:///Users/sophia/code/owid/owid-grapher/packages/@ourworldindata/utils/dist/index.js"; +var __vite_injected_original_dirname3 = "/Users/sophia/code/owid/owid-grapher/settings"; +var baseDir = findProjectBaseDir(__vite_injected_original_dirname3); +if (baseDir === void 0) throw new Error("could not locate base package.json"); +dotenv2.config({ path: `${baseDir}/.env` }); +var serverSettings = process.env ?? {}; +var BASE_DIR = baseDir; +var DATA_API_FOR_ADMIN_UI = serverSettings.DATA_API_FOR_ADMIN_UI; +var BAKED_BASE_URL2 = BAKED_BASE_URL; +var VITE_PREVIEW = serverSettings.VITE_PREVIEW === "true"; +var ADMIN_BASE_URL2 = ADMIN_BASE_URL; +var BAKED_GRAPHER_URL2 = serverSettings.BAKED_GRAPHER_URL ?? `${BAKED_BASE_URL2}/grapher`; +var OPTIMIZE_SVG_EXPORTS = serverSettings.OPTIMIZE_SVG_EXPORTS === "true"; +var GITHUB_USERNAME = serverSettings.GITHUB_USERNAME ?? "owid-test"; +var GIT_DEFAULT_USERNAME = serverSettings.GIT_DEFAULT_USERNAME ?? "Our World in Data"; +var GIT_DEFAULT_EMAIL = serverSettings.GIT_DEFAULT_EMAIL ?? "info@ourworldindata.org"; +var BUGSNAG_API_KEY2 = serverSettings.BUGSNAG_API_KEY; +var BUGSNAG_NODE_API_KEY = serverSettings.BUGSNAG_NODE_API_KEY; +var BLOG_POSTS_PER_PAGE = parseIntOrUndefined2(serverSettings.BLOG_POSTS_PER_PAGE) ?? 21; +var BLOG_SLUG = serverSettings.BLOG_SLUG ?? "latest"; +var GRAPHER_DB_NAME = serverSettings.GRAPHER_DB_NAME ?? "owid"; +var GRAPHER_DB_USER = serverSettings.GRAPHER_DB_USER ?? "root"; +var GRAPHER_DB_PASS = serverSettings.GRAPHER_DB_PASS ?? ""; +var GRAPHER_DB_HOST = serverSettings.GRAPHER_DB_HOST ?? "localhost"; +var GRAPHER_DB_PORT = parseIntOrUndefined2(serverSettings.GRAPHER_DB_PORT) ?? 3306; +var GRAPHER_TEST_DB_NAME = serverSettings.GRAPHER_TEST_DB_NAME ?? "owid"; +var GRAPHER_TEST_DB_USER = serverSettings.GRAPHER_TEST_DB_USER ?? "root"; +var GRAPHER_TEST_DB_PASS = serverSettings.GRAPHER_TEST_DB_PASS ?? ""; +var GRAPHER_TEST_DB_HOST = serverSettings.GRAPHER_TEST_DB_HOST ?? "localhost"; +var GRAPHER_TEST_DB_PORT = parseIntOrUndefined2(serverSettings.GRAPHER_TEST_DB_PORT) ?? 3306; +var BAKED_SITE_DIR = serverSettings.BAKED_SITE_DIR ?? path2.resolve(BASE_DIR, "bakedSite"); +var SECRET_KEY = serverSettings.SECRET_KEY ?? "fejwiaof jewiafo jeioa fjieowajf isa fjidosajfgj"; +var SESSION_COOKIE_AGE = parseIntOrUndefined2(serverSettings.SESSION_COOKIE_AGE) ?? 1209600; +var ALGOLIA_SECRET_KEY = serverSettings.ALGOLIA_SECRET_KEY ?? ""; +var ALGOLIA_INDEXING = serverSettings.ALGOLIA_INDEXING === "true"; +var HTTPS_ONLY = serverSettings.HTTPS_ONLY !== "false"; +var GIT_DATASETS_DIR = serverSettings.GIT_DATASETS_DIR ?? `${BASE_DIR}/datasetsExport`; +var TMP_DIR = serverSettings.TMP_DIR ?? "/tmp"; +var UNCATEGORIZED_TAG_ID = parseIntOrUndefined2(serverSettings.UNCATEGORIZED_TAG_ID) ?? 375; +var BAKE_ON_CHANGE = serverSettings.BAKE_ON_CHANGE === "true"; +var DEPLOY_QUEUE_FILE_PATH = serverSettings.DEPLOY_QUEUE_FILE_PATH ?? `${BASE_DIR}/.queue`; +var DEPLOY_PENDING_FILE_PATH = serverSettings.DEPLOY_PENDING_FILE_PATH ?? `${BASE_DIR}/.pending`; +var CLOUDFLARE_AUD = serverSettings.CLOUDFLARE_AUD ?? ""; +var CATALOG_PATH = serverSettings.CATALOG_PATH ?? ""; +var GDOCS_PRIVATE_KEY = (serverSettings.GDOCS_PRIVATE_KEY ?? "").replaceAll('"', "").replaceAll("'", ""); +var GDOCS_CLIENT_ID = serverSettings.GDOCS_CLIENT_ID ?? ""; +var GDOCS_BACKPORTING_TARGET_FOLDER = serverSettings.GDOCS_BACKPORTING_TARGET_FOLDER ?? ""; +var GDOCS_IMAGES_BACKPORTING_TARGET_FOLDER = serverSettings.GDOCS_IMAGES_BACKPORTING_TARGET_FOLDER ?? ""; +var GDOCS_DONATE_FAQS_DOCUMENT_ID = serverSettings.GDOCS_DONATE_FAQS_DOCUMENT_ID ?? "194PNSFjgSlt9Zm5xYuDOF0l_GLKZbVxH2co3zCok_cE"; +var GDOCS_SHARED_DRIVE_ID = serverSettings.GDOCS_SHARED_DRIVE_ID ?? ""; +var GDOCS_DETAILS_ON_DEMAND_ID2 = serverSettings.GDOCS_DETAILS_ON_DEMAND_ID ?? ""; +var rcloneConfig = {}; +var rcloneConfigPath = path2.join(os.homedir(), ".config/rclone/rclone.conf"); +if (fs2.existsSync(rcloneConfigPath)) { + rcloneConfig = ini.parse(fs2.readFileSync(rcloneConfigPath, "utf-8")); +} +var IMAGE_HOSTING_R2_CDN_URL2 = serverSettings.IMAGE_HOSTING_R2_CDN_URL || ""; +var IMAGE_HOSTING_R2_BUCKET_PATH2 = serverSettings.IMAGE_HOSTING_R2_BUCKET_PATH || ""; +var IMAGE_HOSTING_R2_BUCKET_SUBFOLDER_PATH2 = IMAGE_HOSTING_R2_BUCKET_PATH2.slice( + IMAGE_HOSTING_R2_BUCKET_PATH2.indexOf("/") + 1 +); +var R2_ENDPOINT = serverSettings.R2_ENDPOINT || rcloneConfig["owid-r2"]?.endpoint || "https://078fcdfed9955087315dd86792e71a7e.r2.cloudflarestorage.com"; +var R2_ACCESS_KEY_ID = serverSettings.R2_ACCESS_KEY_ID || rcloneConfig["owid-r2"]?.access_key_id || ""; +var R2_SECRET_ACCESS_KEY = serverSettings.R2_SECRET_ACCESS_KEY || rcloneConfig["owid-r2"]?.secret_access_key || ""; +var R2_REGION = serverSettings.R2_REGION || rcloneConfig["owid-r2"]?.region || "auto"; +var GRAPHER_CONFIG_R2_BUCKET = serverSettings.GRAPHER_CONFIG_R2_BUCKET; +var GRAPHER_CONFIG_R2_BUCKET_PATH = serverSettings.GRAPHER_CONFIG_R2_BUCKET_PATH; +var BUILDKITE_API_ACCESS_TOKEN = serverSettings.BUILDKITE_API_ACCESS_TOKEN ?? ""; +var BUILDKITE_DEPLOY_CONTENT_PIPELINE_SLUG = serverSettings.BUILDKITE_DEPLOY_CONTENT_PIPELINE_SLUG || "owid-deploy-content-master"; +var BUILDKITE_BRANCH = serverSettings.BUILDKITE_BRANCH || "master"; +var BUILDKITE_DEPLOY_CONTENT_SLACK_CHANNEL = serverSettings.BUILDKITE_DEPLOY_CONTENT_SLACK_CHANNEL || "C06EWA0DK4H"; +var OPENAI_API_KEY = serverSettings.OPENAI_API_KEY ?? ""; +var SLACK_BOT_OAUTH_TOKEN = serverSettings.SLACK_BOT_OAUTH_TOKEN ?? ""; +var LEGACY_WORDPRESS_IMAGE_URL = serverSettings.LEGACY_WORDPRESS_IMAGE_URL ?? "https://assets.ourworldindata.org/uploads"; +var ENV_IS_STAGING = ADMIN_BASE_URL2.includes( + "http://staging-site" +); + +// site/SiteConstants.ts +import { faRss } from "file:///Users/sophia/code/owid/owid-grapher/node_modules/@fortawesome/free-solid-svg-icons/index.mjs"; +import { + faXTwitter, + faFacebookSquare, + faInstagram, + faThreads, + faLinkedin, + faBluesky +} from "file:///Users/sophia/code/owid/owid-grapher/node_modules/@fortawesome/free-brands-svg-icons/index.mjs"; +var polyfillFeatures = [ + "es2019", + // Array.flat, Array.flatMap, Object.fromEntries, ... + "es2020", + // String.matchAll, Promise.allSettled, ... + "es2021", + // String.replaceAll, Promise.any, ... + "es2022", + // Array.at, String.at, ... + "es2023", + // Array.findLast, Array.toReversed, Array.toSorted, Array.with, ... + "IntersectionObserver", + "IntersectionObserverEntry", + "ResizeObserver", + "globalThis" + // some dependencies use this +]; +var POLYFILL_VERSION = "4.8.0"; +var POLYFILL_URL = `https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?version=${POLYFILL_VERSION}&features=${polyfillFeatures.join( + "," +)}`; +var DATA_INSIGHTS_ATOM_FEED_NAME = "atom-data-insights.xml"; +var DATA_INSIGHT_ATOM_FEED_PROPS = { + title: "Atom feed for Daily Data Insights", + href: `https://ourworldindata.org/${DATA_INSIGHTS_ATOM_FEED_NAME}` +}; +var RSS_FEEDS = [ + { + title: "Research & Writing RSS Feed", + url: "/atom.xml", + icon: faRss + }, + { + title: "Daily Data Insights RSS Feed", + url: `/${DATA_INSIGHTS_ATOM_FEED_NAME}`, + icon: faRss + } +]; + +// site/viteUtils.tsx +import { sortBy } from "file:///Users/sophia/code/owid/owid-grapher/packages/@ourworldindata/utils/dist/index.js"; +import urljoin from "file:///Users/sophia/code/owid/owid-grapher/node_modules/url-join/lib/url-join.js"; +var VITE_DEV_URL = process.env.VITE_DEV_URL ?? "http://localhost:8090"; +var VITE_ASSET_SITE_ENTRY = "site/owid.entry.ts"; +var VITE_ASSET_ADMIN_ENTRY = "adminSiteClient/admin.entry.ts"; +var VITE_ENTRYPOINT_INFO = { + ["site" /* Site */]: { + entryPointFile: VITE_ASSET_SITE_ENTRY, + outDir: "assets", + outName: "owid" + }, + ["admin" /* Admin */]: { + entryPointFile: VITE_ASSET_ADMIN_ENTRY, + outDir: "assets-admin", + outName: "admin" + } +}; + +// vite.config-common.mts +import { defineConfig } from "file:///Users/sophia/code/owid/owid-grapher/node_modules/vite/dist/node/index.js"; +import pluginReact from "file:///Users/sophia/code/owid/owid-grapher/node_modules/@vitejs/plugin-react/dist/index.mjs"; +import pluginChecker from "file:///Users/sophia/code/owid/owid-grapher/node_modules/vite-plugin-checker/dist/esm/main.js"; +var defineViteConfigForEntrypoint = (entrypoint) => { + const entrypointInfo = VITE_ENTRYPOINT_INFO[entrypoint]; + return defineConfig({ + publicDir: false, + // don't copy public folder to dist + resolve: { + // prettier-ignore + alias: { + "@ourworldindata/grapher/src": "@ourworldindata/grapher/src", + // need this for imports of @ourworldindata/grapher/src/core/grapher.scss to work + // we alias to the packages source files in dev and prod: + // this means we get instant dev updates when we change one of them, + // and the prod build builds them all as esm modules, which helps with tree shaking + // Idea from https://github.com/LinusBorg/vue-lib-template/blob/3775e49b20a7c3349dd49321cad2ed7f9d575057/packages/playground/vite.config.ts + "@ourworldindata/components": "@ourworldindata/components/src/index.ts", + "@ourworldindata/core-table": "@ourworldindata/core-table/src/index.ts", + "@ourworldindata/explorer": "@ourworldindata/explorer/src/index.ts", + "@ourworldindata/grapher": "@ourworldindata/grapher/src/index.ts", + "@ourworldindata/types": "@ourworldindata/types/src/index.ts", + "@ourworldindata/utils": "@ourworldindata/utils/src/index.ts" + } + }, + css: { + devSourcemap: true + }, + define: { + // Replace all clientSettings with their respective values, i.e. assign e.g. BUGSNAG_API_KEY to process.env.BUGSNAG_API_KEY + // it's important to note that we only expose values that are present in the clientSettings file - not any other things that are stored in .env + ...Object.fromEntries( + Object.entries(clientSettings_exports).map(([key, value]) => [ + `process.env.${key}`, + JSON.stringify(value) + ]) + ) + }, + build: { + manifest: true, + // creates a manifest.json file, which we use to determine which files to load in prod + emptyOutDir: true, + outDir: `dist/${entrypointInfo.outDir}`, + sourcemap: true, + target: ["chrome66", "firefox78", "safari12"], + // see docs/browser-support.md + rollupOptions: { + input: { + [entrypointInfo.outName]: entrypointInfo.entryPointFile + }, + output: { + assetFileNames: `${entrypointInfo.outName}.css`, + entryFileNames: `${entrypointInfo.outName}.mjs` + } + } + }, + plugins: [ + pluginReact({ + babel: { + parserOpts: { + plugins: ["decorators-legacy"] + // needed so mobx decorators work correctly + } + } + }), + pluginChecker({ + typescript: { + buildMode: true, + tsconfigPath: "tsconfig.vite-checker.json" + } + }) + ], + server: { + port: 8090, + warmup: { clientFiles: [VITE_ASSET_SITE_ENTRY] } + }, + preview: { + port: 8090 + } + }); +}; + +// vite.config-site.mts +var vite_config_site_default = defineViteConfigForEntrypoint("site" /* Site */); +export { + vite_config_site_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic2l0ZS92aXRlVXRpbHMudHN4IiwgInNldHRpbmdzL2ZpbmRCYXNlRGlyLnRzIiwgInNldHRpbmdzL3NlcnZlclNldHRpbmdzLnRzIiwgInNldHRpbmdzL2NsaWVudFNldHRpbmdzLnRzIiwgInNpdGUvU2l0ZUNvbnN0YW50cy50cyIsICJ2aXRlLmNvbmZpZy1jb21tb24ubXRzIiwgInZpdGUuY29uZmlnLXNpdGUubXRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJpbXBvcnQgUmVhY3QgZnJvbSBcInJlYWN0XCJcbmltcG9ydCBmaW5kQmFzZURpciBmcm9tIFwiLi4vc2V0dGluZ3MvZmluZEJhc2VEaXIuanNcIlxuaW1wb3J0IGZzIGZyb20gXCJmcy1leHRyYVwiXG5pbXBvcnQge1xuICAgIEVOVixcbiAgICBCQUtFRF9CQVNFX1VSTCxcbiAgICBWSVRFX1BSRVZJRVcsXG59IGZyb20gXCIuLi9zZXR0aW5ncy9zZXJ2ZXJTZXR0aW5ncy5qc1wiXG5pbXBvcnQgeyBQT0xZRklMTF9VUkwgfSBmcm9tIFwiLi9TaXRlQ29uc3RhbnRzLmpzXCJcbmltcG9ydCB0eXBlIHsgTWFuaWZlc3QsIE1hbmlmZXN0Q2h1bmsgfSBmcm9tIFwidml0ZVwiXG5pbXBvcnQgeyBzb3J0QnkgfSBmcm9tIFwiQG91cndvcmxkaW5kYXRhL3V0aWxzXCJcbmltcG9ydCB1cmxqb2luIGZyb20gXCJ1cmwtam9pblwiXG5cbmNvbnN0IFZJVEVfREVWX1VSTCA9IHByb2Nlc3MuZW52LlZJVEVfREVWX1VSTCA/PyBcImh0dHA6Ly9sb2NhbGhvc3Q6ODA5MFwiXG5cbmV4cG9ydCBjb25zdCBWSVRFX0FTU0VUX1NJVEVfRU5UUlkgPSBcInNpdGUvb3dpZC5lbnRyeS50c1wiXG5leHBvcnQgY29uc3QgVklURV9BU1NFVF9BRE1JTl9FTlRSWSA9IFwiYWRtaW5TaXRlQ2xpZW50L2FkbWluLmVudHJ5LnRzXCJcblxuZXhwb3J0IGVudW0gVml0ZUVudHJ5UG9pbnQge1xuICAgIFNpdGUgPSBcInNpdGVcIixcbiAgICBBZG1pbiA9IFwiYWRtaW5cIixcbn1cblxuZXhwb3J0IGNvbnN0IFZJVEVfRU5UUllQT0lOVF9JTkZPID0ge1xuICAgIFtWaXRlRW50cnlQb2ludC5TaXRlXToge1xuICAgICAgICBlbnRyeVBvaW50RmlsZTogVklURV9BU1NFVF9TSVRFX0VOVFJZLFxuICAgICAgICBvdXREaXI6IFwiYXNzZXRzXCIsXG4gICAgICAgIG91dE5hbWU6IFwib3dpZFwiLFxuICAgIH0sXG4gICAgW1ZpdGVFbnRyeVBvaW50LkFkbWluXToge1xuICAgICAgICBlbnRyeVBvaW50RmlsZTogVklURV9BU1NFVF9BRE1JTl9FTlRSWSxcbiAgICAgICAgb3V0RGlyOiBcImFzc2V0cy1hZG1pblwiLFxuICAgICAgICBvdXROYW1lOiBcImFkbWluXCIsXG4gICAgfSxcbn1cblxuLy8gV2UgQUxXQVlTIGxvYWQgcG9seWZpbGxzLlxuXG5jb25zdCBwb2x5ZmlsbFNjcmlwdCA9IDxzY3JpcHQga2V5PVwicG9seWZpbGxcIiBzcmM9e1BPTFlGSUxMX1VSTH0gLz5cbmNvbnN0IHBvbHlmaWxsUHJlbG9hZCA9IChcbiAgICA8bGlua1xuICAgICAgICBrZXk9XCJwb2x5ZmlsbC1wcmVsb2FkXCJcbiAgICAgICAgcmVsPVwicHJlbG9hZFwiXG4gICAgICAgIGhyZWY9e1BPTFlGSUxMX1VSTH1cbiAgICAgICAgYXM9XCJzY3JpcHRcIlxuICAgICAgICAvLyBDbG91ZGZsYXJlJ3MgRWFybHkgSGludHMgZ2VuZXJhdGlvbiBmb3IgdGhpcyBVUkwgZnVtYmxlcyB0aGUgYCZhbXA7YCBjb250YWluZWQgaW4gdGhpcyBsaW5rOyBzbyB3ZSBkaXNhYmxlIHRoaXMgZm9yIFwiRWFybHkgSGludHNcIiBmb3Igbm93LlxuICAgICAgICAvLyBTZWUgaHR0cHM6Ly9naXRodWIuY29tL2Nsb3VkZmxhcmUvd29ya2Vycy1zZGsvaXNzdWVzLzY1MjdcbiAgICAgICAgLy8gQ2xvdWRmbGFyZSBkaXNhYmxlcyBFYXJseSBIaW50cyBnZW5lcmF0aW9uIGZvciBhbnkgPGxpbms+IHRoYXQgZG9lc24ndCBqdXN0IGNvbnRhaW4gYHJlbGAsIGBocmVmYCwgYGFzYCAtIHNvIHRoZSBhY3R1YWwgbmFtZSBvZiB0aGlzXG4gICAgICAgIC8vIGF0dHIgZG9lc24ndCBhY3R1YWxseSBtYXR0ZXIuXG4gICAgICAgIGRhdGEtY2xvdWRmbGFyZS1kaXNhYmxlLWVhcmx5LWhpbnRzXG4gICAgLz5cbilcblxuaW50ZXJmYWNlIEFzc2V0cyB7XG4gICAgZm9ySGVhZGVyOiBSZWFjdC5SZWFjdEVsZW1lbnRbXVxuICAgIGZvckZvb3RlcjogUmVhY3QuUmVhY3RFbGVtZW50W11cbn1cblxuLy8gaW4gZGV2OiB3ZSBuZWVkIHRvIGxvYWQgc2V2ZXJhbCB2aXRlIGNvcmUgc2NyaXB0cyBhbmQgcGx1Z2luczsgb3RoZXIgdGhhbiB0aGF0IHdlIG9ubHkgbmVlZCB0byBsb2FkIHRoZSBlbnRyeSBwb2ludCwgYW5kIHZpdGUgd2lsbCB0YWtlIGNhcmUgb2YgdGhlIHJlc3QuXG5jb25zdCBkZXZBc3NldHMgPSAoZW50cnlwb2ludDogVml0ZUVudHJ5UG9pbnQsIGJhc2VVcmw6IHN0cmluZyk6IEFzc2V0cyA9PiB7XG4gICAgcmV0dXJuIHtcbiAgICAgICAgZm9ySGVhZGVyOiBbcG9seWZpbGxQcmVsb2FkXSxcbiAgICAgICAgZm9yRm9vdGVyOiBbXG4gICAgICAgICAgICBwb2x5ZmlsbFNjcmlwdCxcbiAgICAgICAgICAgIDxzY3JpcHRcbiAgICAgICAgICAgICAgICBrZXk9XCJ2aXRlLXJlYWN0LXByZWFtYmxlXCIgLy8gaHR0cHM6Ly92aXRlanMuZGV2L2d1aWRlL2JhY2tlbmQtaW50ZWdyYXRpb24uaHRtbFxuICAgICAgICAgICAgICAgIHR5cGU9XCJtb2R1bGVcIlxuICAgICAgICAgICAgICAgIGRhbmdlcm91c2x5U2V0SW5uZXJIVE1MPXt7XG4gICAgICAgICAgICAgICAgICAgIF9faHRtbDogYGltcG9ydCBSZWZyZXNoUnVudGltZSBmcm9tICcke2Jhc2VVcmx9L0ByZWFjdC1yZWZyZXNoJ1xuICBSZWZyZXNoUnVudGltZS5pbmplY3RJbnRvR2xvYmFsSG9vayh3aW5kb3cpXG4gIHdpbmRvdy4kUmVmcmVzaFJlZyQgPSAoKSA9PiB7fVxuICB3aW5kb3cuJFJlZnJlc2hTaWckID0gKCkgPT4gKHR5cGUpID0+IHR5cGVcbiAgd2luZG93Ll9fdml0ZV9wbHVnaW5fcmVhY3RfcHJlYW1ibGVfaW5zdGFsbGVkX18gPSB0cnVlYCxcbiAgICAgICAgICAgICAgICB9fVxuICAgICAgICAgICAgLz4sXG4gICAgICAgICAgICA8c2NyaXB0XG4gICAgICAgICAgICAgICAga2V5PVwidml0ZS1wbHVnaW4tY2hlY2tlclwiXG4gICAgICAgICAgICAgICAgdHlwZT1cIm1vZHVsZVwiXG4gICAgICAgICAgICAgICAgc3JjPXtgJHtiYXNlVXJsfS9Adml0ZS1wbHVnaW4tY2hlY2tlci1ydW50aW1lLWVudHJ5YH1cbiAgICAgICAgICAgIC8+LFxuICAgICAgICAgICAgPHNjcmlwdFxuICAgICAgICAgICAgICAgIGtleT1cInZpdGUtY2xpZW50XCJcbiAgICAgICAgICAgICAgICB0eXBlPVwibW9kdWxlXCJcbiAgICAgICAgICAgICAgICBzcmM9e2Ake2Jhc2VVcmx9L0B2aXRlL2NsaWVudGB9XG4gICAgICAgICAgICAvPixcbiAgICAgICAgICAgIDxzY3JpcHRcbiAgICAgICAgICAgICAgICBrZXk9e2VudHJ5cG9pbnR9XG4gICAgICAgICAgICAgICAgdHlwZT1cIm1vZHVsZVwiXG4gICAgICAgICAgICAgICAgc3JjPXtgJHtiYXNlVXJsfS8ke1ZJVEVfRU5UUllQT0lOVF9JTkZPW2VudHJ5cG9pbnRdLmVudHJ5UG9pbnRGaWxlfWB9XG4gICAgICAgICAgICAvPixcbiAgICAgICAgXSxcbiAgICB9XG59XG5cbi8vIEdvZXMgdGhyb3VnaCB0aGUgbWFuaWZlc3QuanNvbiBmaWxlcyB0aGF0IHZpdGUgY3JlYXRlcywgZmluZHMgYWxsIHRoZSBhc3NldHMgdGhhdCBhcmUgcmVxdWlyZWQgZm9yIHRoZSBnaXZlbiBlbnRyeSBwb2ludCxcbi8vIGFuZCBjcmVhdGVzIHRoZSBhcHByb3ByaWF0ZSA8bGluaz4gYW5kIDxzY3JpcHQ+IHRhZ3MgZm9yIHRoZW0uXG5leHBvcnQgY29uc3QgY3JlYXRlVGFnc0Zvck1hbmlmZXN0RW50cnkgPSAoXG4gICAgbWFuaWZlc3Q6IE1hbmlmZXN0LFxuICAgIGVudHJ5OiBzdHJpbmcsXG4gICAgYXNzZXRCYXNlVXJsOiBzdHJpbmdcbik6IEFzc2V0cyA9PiB7XG4gICAgY29uc3QgY3JlYXRlVGFncyA9IChlbnRyeTogc3RyaW5nKTogUmVhY3QuUmVhY3RFbGVtZW50W10gPT4ge1xuICAgICAgICBjb25zdCBtYW5pZmVzdEVudHJ5ID1cbiAgICAgICAgICAgIE9iamVjdC52YWx1ZXMobWFuaWZlc3QpLmZpbmQoKGUpID0+IGUuZmlsZSA9PT0gZW50cnkpID8/XG4gICAgICAgICAgICAobWFuaWZlc3RbZW50cnldIGFzIE1hbmlmZXN0Q2h1bmsgfCB1bmRlZmluZWQpXG4gICAgICAgIGxldCBhc3NldHMgPSBbXSBhcyBSZWFjdC5SZWFjdEVsZW1lbnRbXVxuXG4gICAgICAgIGlmICghbWFuaWZlc3RFbnRyeSAmJiAhZW50cnkuZW5kc1dpdGgoXCIuY3NzXCIpKVxuICAgICAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBDb3VsZCBub3QgZmluZCBtYW5pZmVzdCBlbnRyeSBmb3IgJHtlbnRyeX1gKVxuXG4gICAgICAgIGNvbnN0IGFzc2V0VXJsID0gdXJsam9pbihhc3NldEJhc2VVcmwsIG1hbmlmZXN0RW50cnk/LmZpbGUgPz8gZW50cnkpXG5cbiAgICAgICAgaWYgKGVudHJ5LmVuZHNXaXRoKFwiLmNzc1wiKSkge1xuICAgICAgICAgICAgYXNzZXRzID0gW1xuICAgICAgICAgICAgICAgIC4uLmFzc2V0cyxcbiAgICAgICAgICAgICAgICA8bGlua1xuICAgICAgICAgICAgICAgICAgICBrZXk9e2Ake2VudHJ5fS1wcmVsb2FkYH1cbiAgICAgICAgICAgICAgICAgICAgcmVsPVwicHJlbG9hZFwiXG4gICAgICAgICAgICAgICAgICAgIGhyZWY9e2Fzc2V0VXJsfVxuICAgICAgICAgICAgICAgICAgICBhcz1cInN0eWxlXCJcbiAgICAgICAgICAgICAgICAvPixcbiAgICAgICAgICAgICAgICA8bGluayBrZXk9e2VudHJ5fSByZWw9XCJzdHlsZXNoZWV0XCIgaHJlZj17YXNzZXRVcmx9IC8+LFxuICAgICAgICAgICAgXVxuICAgICAgICB9IGVsc2UgaWYgKGVudHJ5Lm1hdGNoKC9cXC5bY21dPyhqc3xqc3h8dHN8dHN4KSQvKSkge1xuICAgICAgICAgICAgLy8gZXhwbGljaXRseSByZWZlcmVuY2UgdGhlIGVudHJ5OyBwcmVsb2FkIGl0IGFuZCBpdHMgZGVwZW5kZW5jaWVzXG4gICAgICAgICAgICBpZiAobWFuaWZlc3RFbnRyeT8uaXNFbnRyeSkge1xuICAgICAgICAgICAgICAgIGFzc2V0cyA9IFtcbiAgICAgICAgICAgICAgICAgICAgLi4uYXNzZXRzLFxuICAgICAgICAgICAgICAgICAgICA8c2NyaXB0XG4gICAgICAgICAgICAgICAgICAgICAgICBrZXk9e2VudHJ5fVxuICAgICAgICAgICAgICAgICAgICAgICAgdHlwZT1cIm1vZHVsZVwiXG4gICAgICAgICAgICAgICAgICAgICAgICBzcmM9e2Fzc2V0VXJsfVxuICAgICAgICAgICAgICAgICAgICAgICAgZGF0YS1hdHRhY2gtb3dpZC1lcnJvci1oYW5kbGVyXG4gICAgICAgICAgICAgICAgICAgIC8+LFxuICAgICAgICAgICAgICAgIF1cbiAgICAgICAgICAgIH1cblxuICAgICAgICAgICAgYXNzZXRzID0gW1xuICAgICAgICAgICAgICAgIC4uLmFzc2V0cyxcbiAgICAgICAgICAgICAgICA8bGlua1xuICAgICAgICAgICAgICAgICAgICBrZXk9e2Ake2VudHJ5fS1wcmVsb2FkYH1cbiAgICAgICAgICAgICAgICAgICAgcmVsPVwibW9kdWxlcHJlbG9hZFwiIC8vIHNlZSBodHRwczovL2RldmVsb3Blci5jaHJvbWUuY29tL2Jsb2cvbW9kdWxlcHJlbG9hZC9cbiAgICAgICAgICAgICAgICAgICAgaHJlZj17YXNzZXRVcmx9XG4gICAgICAgICAgICAgICAgLz4sXG4gICAgICAgICAgICBdXG4gICAgICAgIH1cblxuICAgICAgICAvLyB3ZSBuZWVkIHRvIHJlY3Vyc2UgaW50byBib3RoIHRoZSBtb2R1bGUgaW1wb3J0cyBhbmQgaW1wb3J0ZWQgY3NzIGZpbGVzLCBhbmQgYWRkIHRhZ3MgZm9yIHRoZW0gYXMgd2VsbFxuICAgICAgICAvLyBhbHNvLCB3ZSBuZWVkIHRvIHRha2UgY2FyZSBvZiB0aGUgb3JkZXIgaGVyZSwgc28gdGhlIGltcG9ydGVkIGZpbGUgaXMgbG9hZGVkIGJlZm9yZSB0aGUgaW1wb3J0aW5nIGZpbGVcbiAgICAgICAgaWYgKG1hbmlmZXN0RW50cnk/LmNzcykge1xuICAgICAgICAgICAgYXNzZXRzID0gWy4uLm1hbmlmZXN0RW50cnkuY3NzLmZsYXRNYXAoY3JlYXRlVGFncyksIC4uLmFzc2V0c11cbiAgICAgICAgfVxuICAgICAgICBpZiAobWFuaWZlc3RFbnRyeT8uaW1wb3J0cykge1xuICAgICAgICAgICAgYXNzZXRzID0gWy4uLm1hbmlmZXN0RW50cnkuaW1wb3J0cy5mbGF0TWFwKGNyZWF0ZVRhZ3MpLCAuLi5hc3NldHNdXG4gICAgICAgIH1cbiAgICAgICAgcmV0dXJuIGFzc2V0c1xuICAgIH1cblxuICAgIGNvbnN0IGFzc2V0cyA9IGNyZWF0ZVRhZ3MoZW50cnkpXG4gICAgcmV0dXJuIHtcbiAgICAgICAgZm9ySGVhZGVyOiBhc3NldHMuZmlsdGVyKChlbCkgPT4gZWwudHlwZSA9PT0gXCJsaW5rXCIpLFxuICAgICAgICBmb3JGb290ZXI6IGFzc2V0cy5maWx0ZXIoKGVsKSA9PiBlbC50eXBlID09PSBcInNjcmlwdFwiKSxcbiAgICB9XG59XG5cbi8vIGluIHByb2Q6IHdlIG5lZWQgdG8gbWFrZSBzdXJlIHRoYXQgd2UgaW5jbHVkZSA8c2NyaXB0PiBhbmQgPGxpbms+IHRhZ3MgdGhhdCBhcmUgcmVxdWlyZWQgZm9yIHRoZSBlbnRyeSBwb2ludC5cbi8vIHRoaXMgY291bGQgYmUsIGZvciBleGFtcGxlOiBvd2lkLm1qcywgY29tbW9uLm1qcywgb3dpZC5jc3MsIGNvbW1vbi5jc3MuIChwbHVzIEdvb2dsZSBGb250cyBhbmQgcG9seWZpbGxzKVxuY29uc3QgcHJvZEFzc2V0cyA9IChlbnRyeXBvaW50OiBWaXRlRW50cnlQb2ludCwgYmFzZVVybDogc3RyaW5nKTogQXNzZXRzID0+IHtcbiAgICBjb25zdCBiYXNlRGlyID0gZmluZEJhc2VEaXIoX19kaXJuYW1lKVxuICAgIGNvbnN0IGVudHJ5cG9pbnRJbmZvID0gVklURV9FTlRSWVBPSU5UX0lORk9bZW50cnlwb2ludF1cbiAgICBjb25zdCBtYW5pZmVzdFBhdGggPSBgJHtiYXNlRGlyfS9kaXN0LyR7ZW50cnlwb2ludEluZm8ub3V0RGlyfS8udml0ZS9tYW5pZmVzdC5qc29uYFxuICAgIGxldCBtYW5pZmVzdFxuICAgIHRyeSB7XG4gICAgICAgIG1hbmlmZXN0ID0gZnMucmVhZEpzb25TeW5jKG1hbmlmZXN0UGF0aCkgYXMgTWFuaWZlc3RcbiAgICB9IGNhdGNoIChlcnIpIHtcbiAgICAgICAgdGhyb3cgbmV3IEVycm9yKFxuICAgICAgICAgICAgYENvdWxkIG5vdCByZWFkIHRoZSBidWlsZCBtYW5pZmVzdCAoJyR7bWFuaWZlc3RQYXRofScpLCB3aGljaCBpcyByZXF1aXJlZCBmb3IgcHJvZHVjdGlvbi5cbiAgICAgICAgICAgIElmIHlvdSdyZSBydW5uaW5nIGluIFZJVEVfUFJFVklFVyBtb2RlLCB3YWl0IGZvciB0aGUgYnVpbGQgdG8gZmluaXNoIGFuZCB0aGVuIHJlbG9hZCB0aGlzIHBhZ2UuYCxcbiAgICAgICAgICAgIHsgY2F1c2U6IGVyciB9XG4gICAgICAgIClcbiAgICB9XG5cbiAgICBjb25zdCBhc3NldEJhc2VVcmwgPSBgJHtiYXNlVXJsfS8ke2VudHJ5cG9pbnRJbmZvLm91dERpcn0vYFxuICAgIGNvbnN0IGFzc2V0cyA9IGNyZWF0ZVRhZ3NGb3JNYW5pZmVzdEVudHJ5KFxuICAgICAgICBtYW5pZmVzdCxcbiAgICAgICAgZW50cnlwb2ludEluZm8uZW50cnlQb2ludEZpbGUsXG4gICAgICAgIGFzc2V0QmFzZVVybFxuICAgIClcblxuICAgIHJldHVybiB7XG4gICAgICAgIC8vIHNvcnQgZm9yIHNvbWUga2luZCBvZiBjb25zaXN0ZW5jeTogZmlyc3QgbW9kdWxlcHJlbG9hZCwgdGhlbiBwcmVsb2FkLCB0aGVuIHN0eWxlc2hlZXRcbiAgICAgICAgZm9ySGVhZGVyOiBzb3J0QnkoW3BvbHlmaWxsUHJlbG9hZCwgLi4uYXNzZXRzLmZvckhlYWRlcl0sIFwicHJvcHMucmVsXCIpLFxuICAgICAgICBmb3JGb290ZXI6IFtwb2x5ZmlsbFNjcmlwdCwgLi4uYXNzZXRzLmZvckZvb3Rlcl0sXG4gICAgfVxufVxuXG5jb25zdCB1c2VQcm9kdWN0aW9uQXNzZXRzID0gRU5WID09PSBcInByb2R1Y3Rpb25cIiB8fCBWSVRFX1BSRVZJRVdcblxuY29uc3Qgdml0ZUFzc2V0cyA9IChlbnRyeXBvaW50OiBWaXRlRW50cnlQb2ludCwgcHJvZEJhc2VVcmw/OiBzdHJpbmcpID0+XG4gICAgdXNlUHJvZHVjdGlvbkFzc2V0c1xuICAgICAgICA/IHByb2RBc3NldHMoZW50cnlwb2ludCwgcHJvZEJhc2VVcmwgPz8gXCJcIilcbiAgICAgICAgOiBkZXZBc3NldHMoZW50cnlwb2ludCwgVklURV9ERVZfVVJMKVxuXG5leHBvcnQgY29uc3Qgdml0ZUFzc2V0c0ZvckFkbWluID0gKCkgPT4gdml0ZUFzc2V0cyhWaXRlRW50cnlQb2ludC5BZG1pbilcbmV4cG9ydCBjb25zdCB2aXRlQXNzZXRzRm9yU2l0ZSA9ICgpID0+IHZpdGVBc3NldHMoVml0ZUVudHJ5UG9pbnQuU2l0ZSlcblxuZXhwb3J0IGNvbnN0IGdlbmVyYXRlRW1iZWRTbmlwcGV0ID0gKCkgPT4ge1xuICAgIC8vIE1ha2Ugc3VyZSB3ZSdyZSB1c2luZyBhbiBhYnNvbHV0ZSBVUkwgaGVyZSwgc2luY2Ugd2UgZG9uJ3Qga25vdyBpbiB3aGF0IGNvbnRleHQgdGhlIGVtYmVkIHNuaXBwZXQgaXMgdXNlZC5cbiAgICBjb25zdCBhc3NldHMgPSB2aXRlQXNzZXRzKFZpdGVFbnRyeVBvaW50LlNpdGUsIEJBS0VEX0JBU0VfVVJMKVxuXG4gICAgY29uc3Qgc2VyaWFsaXplZEFzc2V0cyA9IFsuLi5hc3NldHMuZm9ySGVhZGVyLCAuLi5hc3NldHMuZm9yRm9vdGVyXS5tYXAoXG4gICAgICAgIChlbCkgPT4gKHtcbiAgICAgICAgICAgIHRhZzogZWwudHlwZSxcbiAgICAgICAgICAgIHByb3BzOiBlbC5wcm9wcyxcbiAgICAgICAgfSlcbiAgICApXG5cbiAgICBjb25zdCBzY3JpcHRDb3VudCA9IHNlcmlhbGl6ZWRBc3NldHMuZmlsdGVyKFxuICAgICAgICAoYXNzZXQpID0+XG4gICAgICAgICAgICBhc3NldC50YWcgPT09IFwic2NyaXB0XCIgJiYgIWFzc2V0LnByb3BzLmRhbmdlcm91c2x5U2V0SW5uZXJIVE1MIC8vIG9ubG9hZCBkb2Vzbid0IGZpcmUgb24gaW5saW5lIHNjcmlwdHMsIHNvIG5lZWQgdG8gaGFuZGxlIHRoYXQgc2VwYXJhdGVseVxuICAgICkubGVuZ3RoXG5cbiAgICByZXR1cm4gYFxuY29uc3QgYXNzZXRzID0gJHtKU09OLnN0cmluZ2lmeShzZXJpYWxpemVkQXNzZXRzLCB1bmRlZmluZWQsIDIpfTtcbmxldCBsb2FkZWRTY3JpcHRzID0gMDtcblxuY29uc3Qgb25Mb2FkID0gKCkgPT4ge1xuICAgIGxvYWRlZFNjcmlwdHMrKztcbiAgICBpZiAobG9hZGVkU2NyaXB0cyA9PT0gJHtzY3JpcHRDb3VudH0pIHtcbiAgICAgICAgd2luZG93Lk11bHRpRW1iZWRkZXJTaW5nbGV0b24uZW1iZWRBbGwoKTtcbiAgICB9XG59XG5cbmZvciAoY29uc3QgYXNzZXQgb2YgYXNzZXRzKSB7XG4gICAgY29uc3QgZWwgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KGFzc2V0LnRhZyk7XG4gICAgZm9yIChjb25zdCBba2V5LCB2YWx1ZV0gb2YgT2JqZWN0LmVudHJpZXMoYXNzZXQucHJvcHMpKSB7XG4gICAgICAgIGVsLnNldEF0dHJpYnV0ZShrZXksIHZhbHVlKTtcbiAgICB9XG4gICAgaWYgKGFzc2V0LnByb3BzICYmIGFzc2V0LnByb3BzLmRhbmdlcm91c2x5U2V0SW5uZXJIVE1MKSB7XG4gICAgICAgIGVsLnRleHQgPSBhc3NldC5wcm9wcy5kYW5nZXJvdXNseVNldElubmVySFRNTC5fX2h0bWxcbiAgICB9IGVsc2UgaWYgKGFzc2V0LnRhZyA9PT0gXCJzY3JpcHRcIikge1xuICAgICAgICBlbC5vbmxvYWQgPSBvbkxvYWQ7XG4gICAgfVxuICAgIGRvY3VtZW50LmhlYWQuYXBwZW5kQ2hpbGQoZWwpO1xufWBcbn1cbiIsICJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3NldHRpbmdzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvVXNlcnMvc29waGlhL2NvZGUvb3dpZC9vd2lkLWdyYXBoZXIvc2V0dGluZ3MvZmluZEJhc2VEaXIudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3NldHRpbmdzL2ZpbmRCYXNlRGlyLnRzXCI7aW1wb3J0IHBhdGggZnJvbSBcInBhdGhcIlxuaW1wb3J0IGZzIGZyb20gXCJmc1wiXG5cbi8qKlxuICogV2l0aCBvdXIgY29kZSByZXNpZGluZyBlaXRoZXIgaW4gc29tZSBzcmMgZm9sZGVyIG9yIGluIHRoZSBgaXRzSnVzdEphdmFzY3JpcHRgIGZvbGRlciwgaXQncyBub3RcbiAqIGFsd2F5cyBzdHJhaWdodGZvcndhcmQgdG8ga25vdyB3aGVyZSB0byBmaW5kIGEgY29uZmlnIGZpbGUgbGlrZSBgLmVudmAuXG4gKiBIZXJlLCB3ZSBqdXN0IHRyYXZlcnNlIHRoZSBkaXJlY3RvcnkgdHJlZSB1cHdhcmRzIHVudGlsIHdlIGZpbmQgYSBgcGFja2FnZS5qc29uYCBmaWxlLCB3aGljaFxuICogc2hvdWxkIGluZGljYXRlIHRoYXQgd2UgaGF2ZSBmb3VuZCB0aGUgcm9vdCBkaXJlY3Rvcnkgb2YgdGhlIGBvd2lkLWdyYXBoZXJgIHJlcG8uXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIGZpbmRQcm9qZWN0QmFzZURpcihmcm9tOiBzdHJpbmcpOiBzdHJpbmcgfCB1bmRlZmluZWQge1xuICAgIGlmICghZnMuZXhpc3RzU3luYykgcmV0dXJuIHVuZGVmaW5lZCAvLyBpZiBmcy5leGlzdHNTeW5jIGRvZXNuJ3QgZXhpc3QsIHdlJ3JlIHByb2JhYmx5IHJ1bm5pbmcgaW4gdGhlIGJyb3dzZXJcblxuICAgIGxldCBkaXIgPSBwYXRoLmRpcm5hbWUoZnJvbSlcblxuICAgIHdoaWxlIChkaXIubGVuZ3RoKSB7XG4gICAgICAgIGlmIChmcy5leGlzdHNTeW5jKHBhdGgucmVzb2x2ZShkaXIsIFwicGFja2FnZS5qc29uXCIpKSkgcmV0dXJuIGRpclxuXG4gICAgICAgIGNvbnN0IHBhcmVudERpciA9IHBhdGgucmVzb2x2ZShkaXIsIFwiLi5cIilcbiAgICAgICAgLy8gYnJlYWsgaWYgd2UgaGF2ZSByZWFjaGVkIHRoZSBmaWxlIHN5c3RlbSByb290XG4gICAgICAgIGlmIChwYXJlbnREaXIgPT09IGRpcikgYnJlYWtcbiAgICAgICAgZWxzZSBkaXIgPSBwYXJlbnREaXJcbiAgICB9XG5cbiAgICByZXR1cm4gdW5kZWZpbmVkXG59XG4iLCAiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlci9zZXR0aW5nc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3NldHRpbmdzL3NlcnZlclNldHRpbmdzLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlci9zZXR0aW5ncy9zZXJ2ZXJTZXR0aW5ncy50c1wiOy8vIFRoaXMgaXMgd2hlcmUgc2VydmVyLXNpZGUgb25seSwgcG90ZW50aWFsbHkgc2Vuc2l0aXZlIHNldHRpbmdzIGVudGVyIGZyb20gdGhlIGVudmlyb25tZW50XG4vLyBETyBOT1Qgc3RvcmUgc2Vuc2l0aXZlIHN0cmluZ3MgaW4gdGhpcyBmaWxlIGl0c2VsZiwgYXMgaXQgaXMgY2hlY2tlZCBpbiB0byBnaXQhXG5cbmltcG9ydCBwYXRoIGZyb20gXCJwYXRoXCJcbmltcG9ydCBkb3RlbnYgZnJvbSBcImRvdGVudlwiXG5pbXBvcnQgZmluZEJhc2VEaXIgZnJvbSBcIi4vZmluZEJhc2VEaXIuanNcIlxuaW1wb3J0IGZzIGZyb20gXCJmc1wiXG5pbXBvcnQgaW5pIGZyb20gXCJpbmlcIlxuaW1wb3J0IG9zIGZyb20gXCJvc1wiXG5cbmNvbnN0IGJhc2VEaXIgPSBmaW5kQmFzZURpcihfX2Rpcm5hbWUpXG5pZiAoYmFzZURpciA9PT0gdW5kZWZpbmVkKSB0aHJvdyBuZXcgRXJyb3IoXCJjb3VsZCBub3QgbG9jYXRlIGJhc2UgcGFja2FnZS5qc29uXCIpXG5cbmRvdGVudi5jb25maWcoeyBwYXRoOiBgJHtiYXNlRGlyfS8uZW52YCB9KVxuXG5pbXBvcnQgKiBhcyBjbGllbnRTZXR0aW5ncyBmcm9tIFwiLi9jbGllbnRTZXR0aW5ncy5qc1wiXG5pbXBvcnQgeyBwYXJzZUludE9yVW5kZWZpbmVkIH0gZnJvbSBcIkBvdXJ3b3JsZGluZGF0YS91dGlsc1wiXG5cbmNvbnN0IHNlcnZlclNldHRpbmdzID0gcHJvY2Vzcy5lbnYgPz8ge31cblxuZXhwb3J0IGNvbnN0IEJBU0VfRElSOiBzdHJpbmcgPSBiYXNlRGlyXG5leHBvcnQgY29uc3QgRU5WOiBcImRldmVsb3BtZW50XCIgfCBcInByb2R1Y3Rpb25cIiA9IGNsaWVudFNldHRpbmdzLkVOVlxuXG5leHBvcnQgY29uc3QgQURNSU5fU0VSVkVSX1BPUlQ6IG51bWJlciA9IGNsaWVudFNldHRpbmdzLkFETUlOX1NFUlZFUl9QT1JUXG5leHBvcnQgY29uc3QgQURNSU5fU0VSVkVSX0hPU1Q6IHN0cmluZyA9IGNsaWVudFNldHRpbmdzLkFETUlOX1NFUlZFUl9IT1NUXG5leHBvcnQgY29uc3QgREFUQV9BUElfRk9SX0FETUlOX1VJOiBzdHJpbmcgfCB1bmRlZmluZWQgPVxuICAgIHNlcnZlclNldHRpbmdzLkRBVEFfQVBJX0ZPUl9BRE1JTl9VSVxuZXhwb3J0IGNvbnN0IEJBS0VEX0JBU0VfVVJMOiBzdHJpbmcgPSBjbGllbnRTZXR0aW5ncy5CQUtFRF9CQVNFX1VSTFxuXG5leHBvcnQgY29uc3QgVklURV9QUkVWSUVXOiBib29sZWFuID0gc2VydmVyU2V0dGluZ3MuVklURV9QUkVWSUVXID09PSBcInRydWVcIlxuXG5leHBvcnQgY29uc3QgQURNSU5fQkFTRV9VUkw6IHN0cmluZyA9IGNsaWVudFNldHRpbmdzLkFETUlOX0JBU0VfVVJMXG5cbmV4cG9ydCBjb25zdCBCQUtFRF9HUkFQSEVSX1VSTDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5CQUtFRF9HUkFQSEVSX1VSTCA/PyBgJHtCQUtFRF9CQVNFX1VSTH0vZ3JhcGhlcmBcblxuZXhwb3J0IGNvbnN0IE9QVElNSVpFX1NWR19FWFBPUlRTOiBib29sZWFuID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5PUFRJTUlaRV9TVkdfRVhQT1JUUyA9PT0gXCJ0cnVlXCJcblxuZXhwb3J0IGNvbnN0IEdJVEhVQl9VU0VSTkFNRTogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HSVRIVUJfVVNFUk5BTUUgPz8gXCJvd2lkLXRlc3RcIlxuZXhwb3J0IGNvbnN0IEdJVF9ERUZBVUxUX1VTRVJOQU1FOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLkdJVF9ERUZBVUxUX1VTRVJOQU1FID8/IFwiT3VyIFdvcmxkIGluIERhdGFcIlxuZXhwb3J0IGNvbnN0IEdJVF9ERUZBVUxUX0VNQUlMOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLkdJVF9ERUZBVUxUX0VNQUlMID8/IFwiaW5mb0BvdXJ3b3JsZGluZGF0YS5vcmdcIlxuXG5leHBvcnQgY29uc3QgQlVHU05BR19BUElfS0VZOiBzdHJpbmcgfCB1bmRlZmluZWQgPVxuICAgIHNlcnZlclNldHRpbmdzLkJVR1NOQUdfQVBJX0tFWVxuZXhwb3J0IGNvbnN0IEJVR1NOQUdfTk9ERV9BUElfS0VZOiBzdHJpbmcgfCB1bmRlZmluZWQgPVxuICAgIHNlcnZlclNldHRpbmdzLkJVR1NOQUdfTk9ERV9BUElfS0VZXG5cbmV4cG9ydCBjb25zdCBCTE9HX1BPU1RTX1BFUl9QQUdFOiBudW1iZXIgPVxuICAgIHBhcnNlSW50T3JVbmRlZmluZWQoc2VydmVyU2V0dGluZ3MuQkxPR19QT1NUU19QRVJfUEFHRSkgPz8gMjFcbmV4cG9ydCBjb25zdCBCTE9HX1NMVUc6IHN0cmluZyA9IHNlcnZlclNldHRpbmdzLkJMT0dfU0xVRyA/PyBcImxhdGVzdFwiXG5cbmV4cG9ydCBjb25zdCBHUkFQSEVSX0RCX05BTUU6IHN0cmluZyA9IHNlcnZlclNldHRpbmdzLkdSQVBIRVJfREJfTkFNRSA/PyBcIm93aWRcIlxuZXhwb3J0IGNvbnN0IEdSQVBIRVJfREJfVVNFUjogc3RyaW5nID0gc2VydmVyU2V0dGluZ3MuR1JBUEhFUl9EQl9VU0VSID8/IFwicm9vdFwiXG5leHBvcnQgY29uc3QgR1JBUEhFUl9EQl9QQVNTOiBzdHJpbmcgPSBzZXJ2ZXJTZXR0aW5ncy5HUkFQSEVSX0RCX1BBU1MgPz8gXCJcIlxuZXhwb3J0IGNvbnN0IEdSQVBIRVJfREJfSE9TVDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HUkFQSEVSX0RCX0hPU1QgPz8gXCJsb2NhbGhvc3RcIlxuLy8gVGhlIE9XSUQgc3RhY2sgdXNlcyAzMzA3LCBidXQgaW5jYXNlIGl0J3MgdW5zZXQsIGFzc3VtZSB1c2VyIGlzIHJ1bm5pbmcgYSBsb2NhbCBzZXR1cFxuZXhwb3J0IGNvbnN0IEdSQVBIRVJfREJfUE9SVDogbnVtYmVyID1cbiAgICBwYXJzZUludE9yVW5kZWZpbmVkKHNlcnZlclNldHRpbmdzLkdSQVBIRVJfREJfUE9SVCkgPz8gMzMwNlxuXG5leHBvcnQgY29uc3QgR1JBUEhFUl9URVNUX0RCX05BTUU6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuR1JBUEhFUl9URVNUX0RCX05BTUUgPz8gXCJvd2lkXCJcbmV4cG9ydCBjb25zdCBHUkFQSEVSX1RFU1RfREJfVVNFUjogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HUkFQSEVSX1RFU1RfREJfVVNFUiA/PyBcInJvb3RcIlxuZXhwb3J0IGNvbnN0IEdSQVBIRVJfVEVTVF9EQl9QQVNTOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLkdSQVBIRVJfVEVTVF9EQl9QQVNTID8/IFwiXCJcbmV4cG9ydCBjb25zdCBHUkFQSEVSX1RFU1RfREJfSE9TVDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HUkFQSEVSX1RFU1RfREJfSE9TVCA/PyBcImxvY2FsaG9zdFwiXG4vLyBUaGUgT1dJRCBzdGFjayB1c2VzIDMzMDcsIGJ1dCBpbmNhc2UgaXQncyB1bnNldCwgYXNzdW1lIHVzZXIgaXMgcnVubmluZyBhIGxvY2FsIHNldHVwXG5leHBvcnQgY29uc3QgR1JBUEhFUl9URVNUX0RCX1BPUlQ6IG51bWJlciA9XG4gICAgcGFyc2VJbnRPclVuZGVmaW5lZChzZXJ2ZXJTZXR0aW5ncy5HUkFQSEVSX1RFU1RfREJfUE9SVCkgPz8gMzMwNlxuXG5leHBvcnQgY29uc3QgQkFLRURfU0lURV9ESVI6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuQkFLRURfU0lURV9ESVIgPz8gcGF0aC5yZXNvbHZlKEJBU0VfRElSLCBcImJha2VkU2l0ZVwiKSAvLyBXaGVyZSB0aGUgc3RhdGljIGJ1aWxkIG91dHB1dCBnb2VzXG5leHBvcnQgY29uc3QgU0VDUkVUX0tFWTogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5TRUNSRVRfS0VZID8/XG4gICAgXCJmZWp3aWFvZiBqZXdpYWZvIGplaW9hIGZqaWVvd2FqZiBpc2EgZmppZG9zYWpmZ2pcIlxuZXhwb3J0IGNvbnN0IFNFU1NJT05fQ09PS0lFX0FHRTogbnVtYmVyID1cbiAgICBwYXJzZUludE9yVW5kZWZpbmVkKHNlcnZlclNldHRpbmdzLlNFU1NJT05fQ09PS0lFX0FHRSkgPz8gMTIwOTYwMFxuZXhwb3J0IGNvbnN0IEFMR09MSUFfU0VDUkVUX0tFWTogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5BTEdPTElBX1NFQ1JFVF9LRVkgPz8gXCJcIlxuZXhwb3J0IGNvbnN0IEFMR09MSUFfSU5ERVhJTkc6IGJvb2xlYW4gPVxuICAgIHNlcnZlclNldHRpbmdzLkFMR09MSUFfSU5ERVhJTkcgPT09IFwidHJ1ZVwiXG5cbi8vIFdvcmRwcmVzcyB0YXJnZXQgc2V0dGluZ1xuZXhwb3J0IGNvbnN0IEhUVFBTX09OTFk6IGJvb2xlYW4gPSBzZXJ2ZXJTZXR0aW5ncy5IVFRQU19PTkxZICE9PSBcImZhbHNlXCJcblxuZXhwb3J0IGNvbnN0IEdJVF9EQVRBU0VUU19ESVI6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuR0lUX0RBVEFTRVRTX0RJUiA/PyBgJHtCQVNFX0RJUn0vZGF0YXNldHNFeHBvcnRgIC8vICBXaGVyZSB0aGUgZ2l0IGV4cG9ydHMgZ29cbmV4cG9ydCBjb25zdCBUTVBfRElSOiBzdHJpbmcgPSBzZXJ2ZXJTZXR0aW5ncy5UTVBfRElSID8/IFwiL3RtcFwiXG5leHBvcnQgY29uc3QgVU5DQVRFR09SSVpFRF9UQUdfSUQ6IG51bWJlciA9XG4gICAgcGFyc2VJbnRPclVuZGVmaW5lZChzZXJ2ZXJTZXR0aW5ncy5VTkNBVEVHT1JJWkVEX1RBR19JRCkgPz8gMzc1XG5cbi8vIFNob3VsZCB0aGUgc3RhdGljIHNpdGUgb3V0cHV0IGJlIGJha2VkIHdoZW4gcmVsZXZhbnQgZGF0YWJhc2UgaXRlbXMgY2hhbmdlXG5leHBvcnQgY29uc3QgQkFLRV9PTl9DSEFOR0U6IGJvb2xlYW4gPSBzZXJ2ZXJTZXR0aW5ncy5CQUtFX09OX0NIQU5HRSA9PT0gXCJ0cnVlXCJcbmV4cG9ydCBjb25zdCBERVBMT1lfUVVFVUVfRklMRV9QQVRIOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLkRFUExPWV9RVUVVRV9GSUxFX1BBVEggPz8gYCR7QkFTRV9ESVJ9Ly5xdWV1ZWBcbmV4cG9ydCBjb25zdCBERVBMT1lfUEVORElOR19GSUxFX1BBVEg6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuREVQTE9ZX1BFTkRJTkdfRklMRV9QQVRIID8/IGAke0JBU0VfRElSfS8ucGVuZGluZ2BcbmV4cG9ydCBjb25zdCBDTE9VREZMQVJFX0FVRDogc3RyaW5nID0gc2VydmVyU2V0dGluZ3MuQ0xPVURGTEFSRV9BVUQgPz8gXCJcIlxuXG4vLyBFaXRoZXIgcmVtb3RlIGNhdGFsb2cgYGh0dHBzOi8vb3dpZC1jYXRhbG9nLm55YzMuZGlnaXRhbG9jZWFuc3BhY2VzLmNvbS9gIG9yIGxvY2FsIGNhdGFsb2cgYC4uLi9ldGwvZGF0YS9gXG4vLyBOb3RlIHRoYXQgQ2xvdWRmbGFyZSBwcm94eSBvbiBgaHR0cHM6Ly9jYXRhbG9nLm91cndvcmxkaW5kYXRhLm9yZ2AgZG9lcyBub3Qgc3VwcG9ydCByYW5nZSByZXF1ZXN0cyB5ZXRcbi8vIEl0IGlzIGVtcHR5ICh0dXJuZWQgb2ZmKSBieSBkZWZhdWx0IGZvciBub3csIGluIHRoZSBmdXR1cmUgaXQgc2hvdWxkIGJlXG4vLyBgaHR0cHM6Ly9vd2lkLWNhdGFsb2cubnljMy5kaWdpdGFsb2NlYW5zcGFjZXMuY29tL2AgYnkgZGVmYXVsdFxuZXhwb3J0IGNvbnN0IENBVEFMT0dfUEFUSDogc3RyaW5nID0gc2VydmVyU2V0dGluZ3MuQ0FUQUxPR19QQVRIID8/IFwiXCJcblxuLy8gbWFrZSBhbmQgYmFzaCBoYW5kbGUgc3BhY2VzIGluIGVudiB2YXJpYWJsZXMgZGlmZmVyZW50bHkuXG4vLyBubyBxdW90ZXMgLSB3YWl0LWZvci1teXNxbC5zaCB3aWxsIGJyZWFrOiBcIlBSSVZBVEU6IGNvbW1hbmQgbm90IGZvdW5kXCJcbi8vIHF1b3RlcyAtIHdhaXQtZm9yLW15c3FsLnNoIHdpbGwgd29yaywgYnV0IHRoZSB2YXJpYWJsZSB3aWxsIGJlIGRvdWJsZS1xdW90ZWQgaW4gbm9kZTogJ1wiLS0tLS1CRUdJTiBQUklWQVRFIGV0Yy4uLlwiJ1xuLy8gZXNjYXBlZCBzcGFjZXMgLSB3YWl0LWZvci1teXNxbC5zaCB3aWxsIHdvcmssIGJ1dCB0aGUgYmFja3NsYXNoZXMgd2lsbCBleGlzdCBpbiBub2RlOiBcIi0tLS0tQkVHSU5cXCBQUklWQVRFXFwgZXRjLi4uXCJcbmV4cG9ydCBjb25zdCBHRE9DU19QUklWQVRFX0tFWTogc3RyaW5nID0gKFxuICAgIHNlcnZlclNldHRpbmdzLkdET0NTX1BSSVZBVEVfS0VZID8/IFwiXCJcbilcbiAgICAucmVwbGFjZUFsbCgnXCInLCBcIlwiKVxuICAgIC5yZXBsYWNlQWxsKFwiJ1wiLCBcIlwiKVxuZXhwb3J0IGNvbnN0IEdET0NTX0NMSUVOVF9FTUFJTDogc3RyaW5nID0gY2xpZW50U2V0dGluZ3MuR0RPQ1NfQ0xJRU5UX0VNQUlMXG5leHBvcnQgY29uc3QgR0RPQ1NfQ0xJRU5UX0lEOiBzdHJpbmcgPSBzZXJ2ZXJTZXR0aW5ncy5HRE9DU19DTElFTlRfSUQgPz8gXCJcIlxuZXhwb3J0IGNvbnN0IEdET0NTX0JBQ0tQT1JUSU5HX1RBUkdFVF9GT0xERVI6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuR0RPQ1NfQkFDS1BPUlRJTkdfVEFSR0VUX0ZPTERFUiA/PyBcIlwiXG5cbmV4cG9ydCBjb25zdCBHRE9DU19JTUFHRVNfQkFDS1BPUlRJTkdfVEFSR0VUX0ZPTERFUjogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HRE9DU19JTUFHRVNfQkFDS1BPUlRJTkdfVEFSR0VUX0ZPTERFUiA/PyBcIlwiXG5cbmV4cG9ydCBjb25zdCBHRE9DU19ET05BVEVfRkFRU19ET0NVTUVOVF9JRDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HRE9DU19ET05BVEVfRkFRU19ET0NVTUVOVF9JRCA/P1xuICAgIFwiMTk0UE5TRmpnU2x0OVptNXhZdURPRjBsX0dMS1piVnhIMmNvM3pDb2tfY0VcIlxuXG5leHBvcnQgY29uc3QgR0RPQ1NfU0hBUkVEX0RSSVZFX0lEID0gc2VydmVyU2V0dGluZ3MuR0RPQ1NfU0hBUkVEX0RSSVZFX0lEID8/IFwiXCJcblxuZXhwb3J0IGNvbnN0IEdET0NTX0RFVEFJTFNfT05fREVNQU5EX0lEID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5HRE9DU19ERVRBSUxTX09OX0RFTUFORF9JRCA/PyBcIlwiXG5cbi8vIExvYWQgUjIgY3JlZGVudGlhbHMgZnJvbSByY2xvbmUgY29uZmlnXG5sZXQgcmNsb25lQ29uZmlnOiBhbnkgPSB7fVxuY29uc3QgcmNsb25lQ29uZmlnUGF0aCA9IHBhdGguam9pbihvcy5ob21lZGlyKCksIFwiLmNvbmZpZy9yY2xvbmUvcmNsb25lLmNvbmZcIilcbmlmIChmcy5leGlzdHNTeW5jKHJjbG9uZUNvbmZpZ1BhdGgpKSB7XG4gICAgcmNsb25lQ29uZmlnID0gaW5pLnBhcnNlKGZzLnJlYWRGaWxlU3luYyhyY2xvbmVDb25maWdQYXRoLCBcInV0Zi04XCIpKVxufVxuXG4vLyBlLmcuIGh0dHBzOi8vaW1hZ2VzLXN0YWdpbmcub3dpZC5pby9cbmV4cG9ydCBjb25zdCBJTUFHRV9IT1NUSU5HX1IyX0NETl9VUkw6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuSU1BR0VfSE9TVElOR19SMl9DRE5fVVJMIHx8IFwiXCJcbi8vIGUuZy4gb3dpZC1pbWFnZS1ob3N0aW5nLXN0YWdpbmcvZGV2ZWxvcG1lbnRcbmV4cG9ydCBjb25zdCBJTUFHRV9IT1NUSU5HX1IyX0JVQ0tFVF9QQVRIOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLklNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEggfHwgXCJcIlxuLy8gZS5nLiBkZXZlbG9wbWVudFxuZXhwb3J0IGNvbnN0IElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1NVQkZPTERFUl9QQVRIOiBzdHJpbmcgPVxuICAgIElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEguc2xpY2UoXG4gICAgICAgIElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEguaW5kZXhPZihcIi9cIikgKyAxXG4gICAgKVxuLy8gZXh0cmFjdCBSMiBjcmVkZW50aWFscyBmcm9tIHJjbG9uZSBjb25maWcgYXMgZGVmYXVsdHNcbmV4cG9ydCBjb25zdCBSMl9FTkRQT0lOVDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5SMl9FTkRQT0lOVCB8fFxuICAgIHJjbG9uZUNvbmZpZ1tcIm93aWQtcjJcIl0/LmVuZHBvaW50IHx8XG4gICAgXCJodHRwczovLzA3OGZjZGZlZDk5NTUwODczMTVkZDg2NzkyZTcxYTdlLnIyLmNsb3VkZmxhcmVzdG9yYWdlLmNvbVwiXG5leHBvcnQgY29uc3QgUjJfQUNDRVNTX0tFWV9JRDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5SMl9BQ0NFU1NfS0VZX0lEIHx8XG4gICAgcmNsb25lQ29uZmlnW1wib3dpZC1yMlwiXT8uYWNjZXNzX2tleV9pZCB8fFxuICAgIFwiXCJcbmV4cG9ydCBjb25zdCBSMl9TRUNSRVRfQUNDRVNTX0tFWTogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5SMl9TRUNSRVRfQUNDRVNTX0tFWSB8fFxuICAgIHJjbG9uZUNvbmZpZ1tcIm93aWQtcjJcIl0/LnNlY3JldF9hY2Nlc3Nfa2V5IHx8XG4gICAgXCJcIlxuZXhwb3J0IGNvbnN0IFIyX1JFR0lPTjogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5SMl9SRUdJT04gfHwgcmNsb25lQ29uZmlnW1wib3dpZC1yMlwiXT8ucmVnaW9uIHx8IFwiYXV0b1wiXG5cbmV4cG9ydCBjb25zdCBHUkFQSEVSX0NPTkZJR19SMl9CVUNLRVQ6IHN0cmluZyB8IHVuZGVmaW5lZCA9XG4gICAgc2VydmVyU2V0dGluZ3MuR1JBUEhFUl9DT05GSUdfUjJfQlVDS0VUXG5leHBvcnQgY29uc3QgR1JBUEhFUl9DT05GSUdfUjJfQlVDS0VUX1BBVEg6IHN0cmluZyB8IHVuZGVmaW5lZCA9XG4gICAgc2VydmVyU2V0dGluZ3MuR1JBUEhFUl9DT05GSUdfUjJfQlVDS0VUX1BBVEhcblxuZXhwb3J0IGNvbnN0IERBVEFfQVBJX1VSTDogc3RyaW5nID0gY2xpZW50U2V0dGluZ3MuREFUQV9BUElfVVJMXG5cbmV4cG9ydCBjb25zdCBGRUFUVVJFX0ZMQUdTID0gY2xpZW50U2V0dGluZ3MuRkVBVFVSRV9GTEFHU1xuXG5leHBvcnQgY29uc3QgQlVJTERLSVRFX0FQSV9BQ0NFU1NfVE9LRU46IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuQlVJTERLSVRFX0FQSV9BQ0NFU1NfVE9LRU4gPz8gXCJcIlxuZXhwb3J0IGNvbnN0IEJVSUxES0lURV9ERVBMT1lfQ09OVEVOVF9QSVBFTElORV9TTFVHOiBzdHJpbmcgPVxuICAgIHNlcnZlclNldHRpbmdzLkJVSUxES0lURV9ERVBMT1lfQ09OVEVOVF9QSVBFTElORV9TTFVHIHx8XG4gICAgXCJvd2lkLWRlcGxveS1jb250ZW50LW1hc3RlclwiXG5leHBvcnQgY29uc3QgQlVJTERLSVRFX0JSQU5DSDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5CVUlMREtJVEVfQlJBTkNIIHx8IFwibWFzdGVyXCJcbmV4cG9ydCBjb25zdCBCVUlMREtJVEVfREVQTE9ZX0NPTlRFTlRfU0xBQ0tfQ0hBTk5FTDogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5CVUlMREtJVEVfREVQTE9ZX0NPTlRFTlRfU0xBQ0tfQ0hBTk5FTCB8fCBcIkMwNkVXQTBESzRIXCIgLy8gI2NvbnRlbnQtdXBkYXRlc1xuXG5leHBvcnQgY29uc3QgT1BFTkFJX0FQSV9LRVk6IHN0cmluZyA9IHNlcnZlclNldHRpbmdzLk9QRU5BSV9BUElfS0VZID8/IFwiXCJcblxuZXhwb3J0IGNvbnN0IFNMQUNLX0JPVF9PQVVUSF9UT0tFTjogc3RyaW5nID1cbiAgICBzZXJ2ZXJTZXR0aW5ncy5TTEFDS19CT1RfT0FVVEhfVE9LRU4gPz8gXCJcIlxuXG5leHBvcnQgY29uc3QgTEVHQUNZX1dPUkRQUkVTU19JTUFHRV9VUkw6IHN0cmluZyA9XG4gICAgc2VydmVyU2V0dGluZ3MuTEVHQUNZX1dPUkRQUkVTU19JTUFHRV9VUkwgPz9cbiAgICBcImh0dHBzOi8vYXNzZXRzLm91cndvcmxkaW5kYXRhLm9yZy91cGxvYWRzXCJcblxuLy8gc2VhcmNoIGV2YWx1YXRpb25cbmV4cG9ydCBjb25zdCBTRUFSQ0hfRVZBTF9VUkw6IHN0cmluZyA9XG4gICAgXCJodHRwczovL3B1Yi1lYzc2MWZlMGRmNTU0YjAyYmM2MDU2MTBmMzI5NjAwMC5yMi5kZXZcIlxuXG4vLyBXZSBjdXJyZW50bHkgdXNlIEVOVj1wcm9kdWN0aW9uIG9uIHN0YWdpbmcgc2VydmVycywgaXQnZCBiZSBiZXR0ZXIgdG8gaGF2ZSBFTlY9c3RhZ2luZ1xuLy8gYnV0IHRoYXQgd291bGQgcmVxdWlyZSBjaGFuZ2luZyBhIGxvdCBvZiBjb2RlXG5leHBvcnQgY29uc3QgRU5WX0lTX1NUQUdJTkc6IGJvb2xlYW4gPSBBRE1JTl9CQVNFX1VSTC5pbmNsdWRlcyhcbiAgICBcImh0dHA6Ly9zdGFnaW5nLXNpdGVcIlxuKVxuIiwgImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvc29waGlhL2NvZGUvb3dpZC9vd2lkLWdyYXBoZXIvc2V0dGluZ3NcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlci9zZXR0aW5ncy9jbGllbnRTZXR0aW5ncy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvc29waGlhL2NvZGUvb3dpZC9vd2lkLWdyYXBoZXIvc2V0dGluZ3MvY2xpZW50U2V0dGluZ3MudHNcIjsvLyBBbGwgb2YgdGhpcyBpbmZvcm1hdGlvbiBpcyBhdmFpbGFibGUgdG8gdGhlIGNsaWVudC1zaWRlIGNvZGVcbi8vIERPIE5PVCByZXRyaWV2ZSBzZW5zaXRpdmUgaW5mb3JtYXRpb24gZnJvbSB0aGUgZW52aXJvbm1lbnQgaW4gaGVyZSEgOk9cbi8vIFNldHRpbmdzIGluIGhlcmUgd2lsbCBiZSBtYWRlIGF2YWlsYWJsZSB0byB0aGUgY2xpZW50LXNpZGUgY29kZSB0aGF0IGlzXG4vLyBidW5kbGVkIGFuZCBzaGlwcGVkIG91dCB0byBvdXIgdXNlcnMuXG5cbmltcG9ydCBkb3RlbnYgZnJvbSBcImRvdGVudlwiXG5pbXBvcnQgZmluZEJhc2VEaXIgZnJvbSBcIi4vZmluZEJhc2VEaXIuanNcIlxuXG5pZiAodHlwZW9mIF9fZGlybmFtZSAhPT0gXCJ1bmRlZmluZWRcIikge1xuICAgIC8vIG9ubHkgcnVuIHRoaXMgY29kZSBpbiBub2RlLCBub3QgaW4gdGhlIGJyb3dzZXIuXG4gICAgLy8gaW4gdGhlIGJyb3dzZXIsIHByb2Nlc3MuZW52IGlzIGFscmVhZHkgcG9wdWxhdGVkIGJ5IHZpdGUuXG4gICAgY29uc3QgYmFzZURpciA9IGZpbmRCYXNlRGlyKF9fZGlybmFtZSlcbiAgICBpZiAoYmFzZURpcikgZG90ZW52LmNvbmZpZyh7IHBhdGg6IGAke2Jhc2VEaXJ9Ly5lbnZgIH0pXG59XG5cbmltcG9ydCB7IHBhcnNlSW50T3JVbmRlZmluZWQgfSBmcm9tIFwiQG91cndvcmxkaW5kYXRhL3V0aWxzXCJcblxuZXhwb3J0IGNvbnN0IEVOVjogXCJkZXZlbG9wbWVudFwiIHwgXCJwcm9kdWN0aW9uXCIgPVxuICAgIHByb2Nlc3MuZW52LkVOViA9PT0gXCJwcm9kdWN0aW9uXCIgPyBcInByb2R1Y3Rpb25cIiA6IFwiZGV2ZWxvcG1lbnRcIlxuXG5leHBvcnQgY29uc3QgQlVHU05BR19BUElfS0VZOiBzdHJpbmcgfCB1bmRlZmluZWQgPSBwcm9jZXNzLmVudi5CVUdTTkFHX0FQSV9LRVlcbmV4cG9ydCBjb25zdCBTRU5UUllfRFNOOiBzdHJpbmcgfCB1bmRlZmluZWQgPSBwcm9jZXNzLmVudi5TRU5UUllfRFNOXG5leHBvcnQgY29uc3QgQURNSU5fU0VSVkVSX1BPUlQ6IG51bWJlciA9XG4gICAgcGFyc2VJbnRPclVuZGVmaW5lZChwcm9jZXNzLmVudi5BRE1JTl9TRVJWRVJfUE9SVCkgPz8gMzAzMFxuZXhwb3J0IGNvbnN0IEFETUlOX1NFUlZFUl9IT1NUOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LkFETUlOX1NFUlZFUl9IT1NUID8/IFwibG9jYWxob3N0XCJcbmV4cG9ydCBjb25zdCBCQUtFRF9CQVNFX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5CQUtFRF9CQVNFX1VSTCA/P1xuICAgIGBodHRwOi8vJHtBRE1JTl9TRVJWRVJfSE9TVH06JHtBRE1JTl9TRVJWRVJfUE9SVH1gXG5cbmV4cG9ydCBjb25zdCBCQUtFRF9HUkFQSEVSX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5CQUtFRF9HUkFQSEVSX1VSTCA/PyBgJHtCQUtFRF9CQVNFX1VSTH0vZ3JhcGhlcmBcbmV4cG9ydCBjb25zdCBCQUtFRF9HUkFQSEVSX0VYUE9SVFNfQkFTRV9VUkw6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuQkFLRURfR1JBUEhFUl9FWFBPUlRTX0JBU0VfVVJMID8/IGAke0JBS0VEX0dSQVBIRVJfVVJMfS9leHBvcnRzYFxuZXhwb3J0IGNvbnN0IEJBS0VEX1NJVEVfRVhQT1JUU19CQVNFX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5CQUtFRF9TSVRFX0VYUE9SVFNfQkFTRV9VUkwgPz8gYCR7QkFLRURfQkFTRV9VUkx9L2V4cG9ydHNgXG5cbmV4cG9ydCBjb25zdCBHUkFQSEVSX0RZTkFNSUNfVEhVTUJOQUlMX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5HUkFQSEVSX0RZTkFNSUNfVEhVTUJOQUlMX1VSTCA/PyBgJHtCQUtFRF9HUkFQSEVSX1VSTH1gXG5cbmV4cG9ydCBjb25zdCBFWFBMT1JFUl9EWU5BTUlDX1RIVU1CTkFJTF9VUkw6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuRVhQTE9SRVJfRFlOQU1JQ19USFVNQk5BSUxfVVJMID8/IGAke0JBS0VEX0JBU0VfVVJMfS9leHBsb3JlcnNgXG5cbmV4cG9ydCBjb25zdCBHUkFQSEVSX0RZTkFNSUNfQ09ORklHX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5HUkFQSEVSX0RZTkFNSUNfQ09ORklHX1VSTCA/PyBgJHtCQUtFRF9HUkFQSEVSX1VSTH1gXG5cbmV4cG9ydCBjb25zdCBNVUxUSV9ESU1fRFlOQU1JQ19DT05GSUdfVVJMOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52Lk1VTFRJX0RJTV9EWU5BTUlDX0NPTkZJR19VUkwgPz8gYCR7QkFLRURfQkFTRV9VUkx9L211bHRpLWRpbWBcblxuZXhwb3J0IGNvbnN0IEFETUlOX0JBU0VfVVJMOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LkFETUlOX0JBU0VfVVJMID8/XG4gICAgYGh0dHA6Ly8ke0FETUlOX1NFUlZFUl9IT1NUfToke0FETUlOX1NFUlZFUl9QT1JUfWBcbi8vIGUuZy4gXCJodHRwczovL2FwaS5vdXJ3b3JsZGluZGF0YS5vcmcvdjEvaW5kaWNhdG9ycy9cIiBvciBcImh0dHBzOi8vYXBpLXN0YWdpbmcub3dpZC5pby91c2VyL3YxL2luZGljYXRvcnMvXCJcbmV4cG9ydCBjb25zdCBEQVRBX0FQSV9VUkw6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuREFUQV9BUElfVVJMID8/IFwiaHR0cHM6Ly9hcGkub3Vyd29ybGRpbmRhdGEub3JnL3YxL2luZGljYXRvcnMvXCJcblxuZXhwb3J0IGNvbnN0IEFMR09MSUFfSUQ6IHN0cmluZyA9IHByb2Nlc3MuZW52LkFMR09MSUFfSUQgPz8gXCJcIlxuZXhwb3J0IGNvbnN0IEFMR09MSUFfU0VBUkNIX0tFWTogc3RyaW5nID0gcHJvY2Vzcy5lbnYuQUxHT0xJQV9TRUFSQ0hfS0VZID8/IFwiXCJcbmV4cG9ydCBjb25zdCBBTEdPTElBX0lOREVYX1BSRUZJWDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5BTEdPTElBX0lOREVYX1BSRUZJWCA/PyBcIlwiXG5cbmV4cG9ydCBjb25zdCBET05BVEVfQVBJX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5ET05BVEVfQVBJX1VSTCA/PyBcImh0dHA6Ly9sb2NhbGhvc3Q6ODc4OC9kb25hdGlvbi9kb25hdGVcIlxuXG5leHBvcnQgY29uc3QgUkVDQVBUQ0hBX1NJVEVfS0VZOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LlJFQ0FQVENIQV9TSVRFX0tFWSA/PyBcIjZMY0psNVlVQUFBQUFBVFE2RjR2bDlkQVdSWmVLUEJtMTVNQVpqNFFcIlxuXG4vLyBlLmcuIFwiR1RNLU4yRDRWOFNcIiAob3VyIHByb2R1Y3Rpb24gR1RNIGNvbnRhaW5lcilcbmV4cG9ydCBjb25zdCBHT09HTEVfVEFHX01BTkFHRVJfSUQ6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuR09PR0xFX1RBR19NQU5BR0VSX0lEID8/IFwiXCJcblxuZXhwb3J0IGNvbnN0IFRPUElDU19DT05URU5UX0dSQVBIOiBib29sZWFuID1cbiAgICBwcm9jZXNzLmVudi5UT1BJQ1NfQ09OVEVOVF9HUkFQSCA9PT0gXCJ0cnVlXCJcblxuZXhwb3J0IGNvbnN0IEdET0NTX0NMSUVOVF9FTUFJTDogc3RyaW5nID0gcHJvY2Vzcy5lbnYuR0RPQ1NfQ0xJRU5UX0VNQUlMID8/IFwiXCJcbmV4cG9ydCBjb25zdCBHRE9DU19CQVNJQ19BUlRJQ0xFX1RFTVBMQVRFX1VSTDogc3RyaW5nID1cbiAgICBwcm9jZXNzLmVudi5HRE9DU19CQVNJQ19BUlRJQ0xFX1RFTVBMQVRFX1VSTCA/PyBcIlwiXG5cbmV4cG9ydCBjb25zdCBJTUFHRV9IT1NUSU5HX1IyX0NETl9VUkw6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuSU1BR0VfSE9TVElOR19SMl9DRE5fVVJMIHx8IFwiXCJcbi8vIGUuZy4gb3dpZC1pbWFnZS1ob3N0aW5nLXN0YWdpbmcvZGV2ZWxvcG1lbnRcbmV4cG9ydCBjb25zdCBJTUFHRV9IT1NUSU5HX1IyX0JVQ0tFVF9QQVRIOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LklNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEggfHwgXCJcIlxuLy8gZS5nLiBkZXZlbG9wbWVudFxuZXhwb3J0IGNvbnN0IElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1NVQkZPTERFUl9QQVRIOiBzdHJpbmcgPVxuICAgIElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEguc2xpY2UoXG4gICAgICAgIElNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEguaW5kZXhPZihcIi9cIikgKyAxXG4gICAgKVxuXG4vLyBMaW5rIHRvIHByb2R1Y3Rpb24gd2l6YXJkLiAgWW91IG5lZWQgVGFpbHNjYWxlIHRvIGFjY2VzcyBpdCBpbiBwcm9kdWN0aW9uLlxuZXhwb3J0IGNvbnN0IEVUTF9XSVpBUkRfVVJMOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LkVUTF9XSVpBUkRfVVJMID8/IGBodHRwOi8vJHtBRE1JTl9TRVJWRVJfSE9TVH06ODA1My9gXG5cbi8vIFByb2R1Y3Rpb24gRVRMIEFQSSBydW5zIG9uIGh0dHA6Ly9ldGwtcHJvZC0yOjgwODMvdjEgKHlvdSBuZWVkIFRhaWxzY2FsZSB0byBhY2Nlc3MgaXQpXG5leHBvcnQgY29uc3QgRVRMX0FQSV9VUkw6IHN0cmluZyA9XG4gICAgcHJvY2Vzcy5lbnYuRVRMX0FQSV9VUkwgPz8gYGh0dHA6Ly8ke0FETUlOX1NFUlZFUl9IT1NUfTo4MDgxL2FwaS92MWBcblxuZXhwb3J0IGNvbnN0IEdET0NTX0RFVEFJTFNfT05fREVNQU5EX0lEOiBzdHJpbmcgPVxuICAgIHByb2Nlc3MuZW52LkdET0NTX0RFVEFJTFNfT05fREVNQU5EX0lEID8/IFwiXCJcblxuZXhwb3J0IGNvbnN0IFBVQkxJU0hFRF9BVF9GT1JNQVQgPSBcImRkZCwgTU1NIEQsIFlZWVkgSEg6bW1cIlxuXG4vLyBGZWF0dXJlIGZsYWdzOiBGRUFUVVJFX0ZMQUdTIGlzIGEgY29tbWEtc2VwYXJhdGVkIGxpc3Qgb2YgZmxhZ3MsIGFuZCB0aGV5IG5lZWQgdG8gYmUgcGFydCBvZiB0aGlzIGVudW0gdG8gYmUgY29uc2lkZXJlZFxuZXhwb3J0IGVudW0gRmVhdHVyZUZsYWdGZWF0dXJlIHtcbiAgICBNdWx0aURpbURhdGFQYWdlID0gXCJNdWx0aURpbURhdGFQYWdlXCIsXG59XG5jb25zdCBmZWF0dXJlRmxhZ3NSYXcgPVxuICAgICh0eXBlb2YgcHJvY2Vzcy5lbnYuRkVBVFVSRV9GTEFHUyA9PT0gXCJzdHJpbmdcIiAmJlxuICAgICAgICBwcm9jZXNzLmVudi5GRUFUVVJFX0ZMQUdTLnRyaW0oKT8uc3BsaXQoXCIsXCIpKSB8fFxuICAgIFtdXG5leHBvcnQgY29uc3QgRkVBVFVSRV9GTEFHUzogU2V0PEZlYXR1cmVGbGFnRmVhdHVyZT4gPSBuZXcgU2V0KFxuICAgIE9iamVjdC5rZXlzKEZlYXR1cmVGbGFnRmVhdHVyZSkuZmlsdGVyKChrZXkpID0+XG4gICAgICAgIGZlYXR1cmVGbGFnc1Jhdy5pbmNsdWRlcyhrZXkpXG4gICAgKSBhcyBGZWF0dXJlRmxhZ0ZlYXR1cmVbXVxuKVxuIiwgImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvc29waGlhL2NvZGUvb3dpZC9vd2lkLWdyYXBoZXIvc2l0ZVwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3NpdGUvU2l0ZUNvbnN0YW50cy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvc29waGlhL2NvZGUvb3dpZC9vd2lkLWdyYXBoZXIvc2l0ZS9TaXRlQ29uc3RhbnRzLnRzXCI7aW1wb3J0IHsgZmFSc3MgfSBmcm9tIFwiQGZvcnRhd2Vzb21lL2ZyZWUtc29saWQtc3ZnLWljb25zXCJcbmltcG9ydCB7XG4gICAgZmFYVHdpdHRlcixcbiAgICBmYUZhY2Vib29rU3F1YXJlLFxuICAgIGZhSW5zdGFncmFtLFxuICAgIGZhVGhyZWFkcyxcbiAgICBmYUxpbmtlZGluLFxuICAgIGZhQmx1ZXNreSxcbn0gZnJvbSBcIkBmb3J0YXdlc29tZS9mcmVlLWJyYW5kcy1zdmctaWNvbnNcIlxuXG4vLyBTZWUgaHR0cHM6Ly9jZG5qcy5jbG91ZGZsYXJlLmNvbS9wb2x5ZmlsbC8gZm9yIGEgbGlzdCBvZiBhbGwgc3VwcG9ydGVkIGZlYXR1cmVzXG5jb25zdCBwb2x5ZmlsbEZlYXR1cmVzID0gW1xuICAgIFwiZXMyMDE5XCIsIC8vIEFycmF5LmZsYXQsIEFycmF5LmZsYXRNYXAsIE9iamVjdC5mcm9tRW50cmllcywgLi4uXG4gICAgXCJlczIwMjBcIiwgLy8gU3RyaW5nLm1hdGNoQWxsLCBQcm9taXNlLmFsbFNldHRsZWQsIC4uLlxuICAgIFwiZXMyMDIxXCIsIC8vIFN0cmluZy5yZXBsYWNlQWxsLCBQcm9taXNlLmFueSwgLi4uXG4gICAgXCJlczIwMjJcIiwgLy8gQXJyYXkuYXQsIFN0cmluZy5hdCwgLi4uXG4gICAgXCJlczIwMjNcIiwgLy8gQXJyYXkuZmluZExhc3QsIEFycmF5LnRvUmV2ZXJzZWQsIEFycmF5LnRvU29ydGVkLCBBcnJheS53aXRoLCAuLi5cbiAgICBcIkludGVyc2VjdGlvbk9ic2VydmVyXCIsXG4gICAgXCJJbnRlcnNlY3Rpb25PYnNlcnZlckVudHJ5XCIsXG4gICAgXCJSZXNpemVPYnNlcnZlclwiLFxuICAgIFwiZ2xvYmFsVGhpc1wiLCAvLyBzb21lIGRlcGVuZGVuY2llcyB1c2UgdGhpc1xuXVxuY29uc3QgUE9MWUZJTExfVkVSU0lPTiA9IFwiNC44LjBcIlxuZXhwb3J0IGNvbnN0IFBPTFlGSUxMX1VSTDogc3RyaW5nID0gYGh0dHBzOi8vY2RuanMuY2xvdWRmbGFyZS5jb20vcG9seWZpbGwvdjMvcG9seWZpbGwubWluLmpzP3ZlcnNpb249JHtQT0xZRklMTF9WRVJTSU9OfSZmZWF0dXJlcz0ke3BvbHlmaWxsRmVhdHVyZXMuam9pbihcbiAgICBcIixcIlxuKX1gXG5cbmV4cG9ydCBjb25zdCBERUZBVUxUX0xPQ0FMX0JBS0VfRElSID0gXCJsb2NhbEJha2VcIlxuXG5leHBvcnQgY29uc3QgR1JBUEhFUl9QUkVWSUVXX0NMQVNTID0gXCJncmFwaGVyUHJldmlld1wiXG5cbmV4cG9ydCBjb25zdCBTTUFMTF9CUkVBS1BPSU5UX01FRElBX1FVRVJZID0gXCIobWF4LXdpZHRoOiA3NjhweClcIlxuXG5leHBvcnQgY29uc3QgVE9VQ0hfREVWSUNFX01FRElBX1FVRVJZID1cbiAgICBcIihob3Zlcjogbm9uZSksIChwb2ludGVyOiBjb2Fyc2UpLCAocG9pbnRlcjogbm9uZSlcIlxuXG5leHBvcnQgY29uc3QgREFUQV9JTlNJR0hUU19BVE9NX0ZFRURfTkFNRSA9IFwiYXRvbS1kYXRhLWluc2lnaHRzLnhtbFwiXG5cbmV4cG9ydCBjb25zdCBEQVRBX0lOU0lHSFRfQVRPTV9GRUVEX1BST1BTID0ge1xuICAgIHRpdGxlOiBcIkF0b20gZmVlZCBmb3IgRGFpbHkgRGF0YSBJbnNpZ2h0c1wiLFxuICAgIGhyZWY6IGBodHRwczovL291cndvcmxkaW5kYXRhLm9yZy8ke0RBVEFfSU5TSUdIVFNfQVRPTV9GRUVEX05BTUV9YCxcbn1cblxuZXhwb3J0IGNvbnN0IERFRkFVTFRfVE9NQlNUT05FX1JFQVNPTiA9XG4gICAgXCJPdXIgV29ybGQgaW4gRGF0YSBpcyBkZXNpZ25lZCB0byBiZSBhbiBldmVyZ3JlZW4gcHVibGljYXRpb24uIFRoaXMgXCIgK1xuICAgIFwibWVhbnMgdGhhdCB3aGVuIGEgcGFnZSBjYW5ub3QgYmUgdXBkYXRlZCBkdWUgdG8gb3V0ZGF0ZWQgZGF0YSBvciBcIiArXG4gICAgXCJtaXNzaW5nIGluZm9ybWF0aW9uLCB3ZSBwcmVmZXIgdG8gcmVtb3ZlIGl0IHJhdGhlciB0aGFuIHByZXNlbnQgXCIgK1xuICAgIFwiaW5jb21wbGV0ZSBvciBpbmFjY3VyYXRlIHJlc2VhcmNoIGFuZCBkYXRhIHRvIG91ciByZWFkZXJzLlwiXG5cbmV4cG9ydCBjb25zdCBTT0NJQUxTID0gW1xuICAgIHtcbiAgICAgICAgdGl0bGU6IFwiWFwiLFxuICAgICAgICB1cmw6IFwiaHR0cHM6Ly94LmNvbS9vdXJ3b3JsZGluZGF0YVwiLFxuICAgICAgICBpY29uOiBmYVhUd2l0dGVyLFxuICAgIH0sXG4gICAge1xuICAgICAgICB0aXRsZTogXCJJbnN0YWdyYW1cIixcbiAgICAgICAgdXJsOiBcImh0dHBzOi8vd3d3Lmluc3RhZ3JhbS5jb20vb3Vyd29ybGRpbmRhdGEvXCIsXG4gICAgICAgIGljb246IGZhSW5zdGFncmFtLFxuICAgIH0sXG4gICAge1xuICAgICAgICB0aXRsZTogXCJUaHJlYWRzXCIsXG4gICAgICAgIHVybDogXCJodHRwczovL3d3dy50aHJlYWRzLm5ldC9Ab3Vyd29ybGRpbmRhdGFcIixcbiAgICAgICAgaWNvbjogZmFUaHJlYWRzLFxuICAgIH0sXG4gICAge1xuICAgICAgICB0aXRsZTogXCJGYWNlYm9va1wiLFxuICAgICAgICB1cmw6IFwiaHR0cHM6Ly9mYWNlYm9vay5jb20vb3Vyd29ybGRpbmRhdGFcIixcbiAgICAgICAgaWNvbjogZmFGYWNlYm9va1NxdWFyZSxcbiAgICB9LFxuICAgIHtcbiAgICAgICAgdGl0bGU6IFwiTGlua2VkSW5cIixcbiAgICAgICAgdXJsOiBcImh0dHBzOi8vd3d3LmxpbmtlZGluLmNvbS9jb21wYW55L291cndvcmxkaW5kYXRhXCIsXG4gICAgICAgIGljb246IGZhTGlua2VkaW4sXG4gICAgfSxcbiAgICB7XG4gICAgICAgIHRpdGxlOiBcIkJsdWVza3lcIixcbiAgICAgICAgdXJsOiBcImh0dHBzOi8vYnNreS5hcHAvcHJvZmlsZS9vdXJ3b3JsZGluZGF0YS5vcmdcIixcbiAgICAgICAgaWNvbjogZmFCbHVlc2t5LFxuICAgIH0sXG5dXG5cbmV4cG9ydCBjb25zdCBSU1NfRkVFRFMgPSBbXG4gICAge1xuICAgICAgICB0aXRsZTogXCJSZXNlYXJjaCAmIFdyaXRpbmcgUlNTIEZlZWRcIixcbiAgICAgICAgdXJsOiBcIi9hdG9tLnhtbFwiLFxuICAgICAgICBpY29uOiBmYVJzcyxcbiAgICB9LFxuICAgIHtcbiAgICAgICAgdGl0bGU6IFwiRGFpbHkgRGF0YSBJbnNpZ2h0cyBSU1MgRmVlZFwiLFxuICAgICAgICB1cmw6IGAvJHtEQVRBX0lOU0lHSFRTX0FUT01fRkVFRF9OQU1FfWAsXG4gICAgICAgIGljb246IGZhUnNzLFxuICAgIH0sXG5dXG4iLCAiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlclwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3ZpdGUuY29uZmlnLWNvbW1vbi5tdHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3ZpdGUuY29uZmlnLWNvbW1vbi5tdHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXG5pbXBvcnQgcGx1Z2luUmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCJcbmltcG9ydCBwbHVnaW5DaGVja2VyIGZyb20gXCJ2aXRlLXBsdWdpbi1jaGVja2VyXCJcbmltcG9ydCAqIGFzIGNsaWVudFNldHRpbmdzIGZyb20gXCIuL3NldHRpbmdzL2NsaWVudFNldHRpbmdzLmpzXCJcbmltcG9ydCB7XG4gICAgVklURV9BU1NFVF9TSVRFX0VOVFJZLFxuICAgIFZJVEVfRU5UUllQT0lOVF9JTkZPLFxuICAgIFZpdGVFbnRyeVBvaW50LFxufSBmcm9tIFwiLi9zaXRlL3ZpdGVVdGlscy5qc1wiXG5cbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXG5leHBvcnQgY29uc3QgZGVmaW5lVml0ZUNvbmZpZ0ZvckVudHJ5cG9pbnQgPSAoZW50cnlwb2ludDogVml0ZUVudHJ5UG9pbnQpID0+IHtcbiAgICBjb25zdCBlbnRyeXBvaW50SW5mbyA9IFZJVEVfRU5UUllQT0lOVF9JTkZPW2VudHJ5cG9pbnRdXG5cbiAgICByZXR1cm4gZGVmaW5lQ29uZmlnKHtcbiAgICAgICAgcHVibGljRGlyOiBmYWxzZSwgLy8gZG9uJ3QgY29weSBwdWJsaWMgZm9sZGVyIHRvIGRpc3RcbiAgICAgICAgcmVzb2x2ZToge1xuICAgICAgICAgICAgLy8gcHJldHRpZXItaWdub3JlXG4gICAgICAgICAgICBhbGlhczoge1xuICAgICAgICAgICAgICAgIFwiQG91cndvcmxkaW5kYXRhL2dyYXBoZXIvc3JjXCI6IFwiQG91cndvcmxkaW5kYXRhL2dyYXBoZXIvc3JjXCIsIC8vIG5lZWQgdGhpcyBmb3IgaW1wb3J0cyBvZiBAb3Vyd29ybGRpbmRhdGEvZ3JhcGhlci9zcmMvY29yZS9ncmFwaGVyLnNjc3MgdG8gd29ya1xuICAgICAgICAgICAgICAgIC8vIHdlIGFsaWFzIHRvIHRoZSBwYWNrYWdlcyBzb3VyY2UgZmlsZXMgaW4gZGV2IGFuZCBwcm9kOlxuICAgICAgICAgICAgICAgIC8vIHRoaXMgbWVhbnMgd2UgZ2V0IGluc3RhbnQgZGV2IHVwZGF0ZXMgd2hlbiB3ZSBjaGFuZ2Ugb25lIG9mIHRoZW0sXG4gICAgICAgICAgICAgICAgLy8gYW5kIHRoZSBwcm9kIGJ1aWxkIGJ1aWxkcyB0aGVtIGFsbCBhcyBlc20gbW9kdWxlcywgd2hpY2ggaGVscHMgd2l0aCB0cmVlIHNoYWtpbmdcbiAgICAgICAgICAgICAgICAvLyBJZGVhIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL0xpbnVzQm9yZy92dWUtbGliLXRlbXBsYXRlL2Jsb2IvMzc3NWU0OWIyMGE3YzMzNDlkZDQ5MzIxY2FkMmVkN2Y5ZDU3NTA1Ny9wYWNrYWdlcy9wbGF5Z3JvdW5kL3ZpdGUuY29uZmlnLnRzXG4gICAgICAgICAgICAgICAgXCJAb3Vyd29ybGRpbmRhdGEvY29tcG9uZW50c1wiOiBcIkBvdXJ3b3JsZGluZGF0YS9jb21wb25lbnRzL3NyYy9pbmRleC50c1wiLFxuICAgICAgICAgICAgICAgIFwiQG91cndvcmxkaW5kYXRhL2NvcmUtdGFibGVcIjogXCJAb3Vyd29ybGRpbmRhdGEvY29yZS10YWJsZS9zcmMvaW5kZXgudHNcIixcbiAgICAgICAgICAgICAgICBcIkBvdXJ3b3JsZGluZGF0YS9leHBsb3JlclwiOiBcIkBvdXJ3b3JsZGluZGF0YS9leHBsb3Jlci9zcmMvaW5kZXgudHNcIixcbiAgICAgICAgICAgICAgICBcIkBvdXJ3b3JsZGluZGF0YS9ncmFwaGVyXCI6IFwiQG91cndvcmxkaW5kYXRhL2dyYXBoZXIvc3JjL2luZGV4LnRzXCIsXG4gICAgICAgICAgICAgICAgXCJAb3Vyd29ybGRpbmRhdGEvdHlwZXNcIjogXCJAb3Vyd29ybGRpbmRhdGEvdHlwZXMvc3JjL2luZGV4LnRzXCIsXG4gICAgICAgICAgICAgICAgXCJAb3Vyd29ybGRpbmRhdGEvdXRpbHNcIjogXCJAb3Vyd29ybGRpbmRhdGEvdXRpbHMvc3JjL2luZGV4LnRzXCIsXG4gICAgICAgICAgICB9LFxuICAgICAgICB9LFxuICAgICAgICBjc3M6IHtcbiAgICAgICAgICAgIGRldlNvdXJjZW1hcDogdHJ1ZSxcbiAgICAgICAgfSxcbiAgICAgICAgZGVmaW5lOiB7XG4gICAgICAgICAgICAvLyBSZXBsYWNlIGFsbCBjbGllbnRTZXR0aW5ncyB3aXRoIHRoZWlyIHJlc3BlY3RpdmUgdmFsdWVzLCBpLmUuIGFzc2lnbiBlLmcuIEJVR1NOQUdfQVBJX0tFWSB0byBwcm9jZXNzLmVudi5CVUdTTkFHX0FQSV9LRVlcbiAgICAgICAgICAgIC8vIGl0J3MgaW1wb3J0YW50IHRvIG5vdGUgdGhhdCB3ZSBvbmx5IGV4cG9zZSB2YWx1ZXMgdGhhdCBhcmUgcHJlc2VudCBpbiB0aGUgY2xpZW50U2V0dGluZ3MgZmlsZSAtIG5vdCBhbnkgb3RoZXIgdGhpbmdzIHRoYXQgYXJlIHN0b3JlZCBpbiAuZW52XG4gICAgICAgICAgICAuLi5PYmplY3QuZnJvbUVudHJpZXMoXG4gICAgICAgICAgICAgICAgT2JqZWN0LmVudHJpZXMoY2xpZW50U2V0dGluZ3MpLm1hcCgoW2tleSwgdmFsdWVdKSA9PiBbXG4gICAgICAgICAgICAgICAgICAgIGBwcm9jZXNzLmVudi4ke2tleX1gLFxuICAgICAgICAgICAgICAgICAgICBKU09OLnN0cmluZ2lmeSh2YWx1ZSksXG4gICAgICAgICAgICAgICAgXSlcbiAgICAgICAgICAgICksXG4gICAgICAgIH0sXG4gICAgICAgIGJ1aWxkOiB7XG4gICAgICAgICAgICBtYW5pZmVzdDogdHJ1ZSwgLy8gY3JlYXRlcyBhIG1hbmlmZXN0Lmpzb24gZmlsZSwgd2hpY2ggd2UgdXNlIHRvIGRldGVybWluZSB3aGljaCBmaWxlcyB0byBsb2FkIGluIHByb2RcbiAgICAgICAgICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICAgICAgICAgICAgb3V0RGlyOiBgZGlzdC8ke2VudHJ5cG9pbnRJbmZvLm91dERpcn1gLFxuICAgICAgICAgICAgc291cmNlbWFwOiB0cnVlLFxuICAgICAgICAgICAgdGFyZ2V0OiBbXCJjaHJvbWU2NlwiLCBcImZpcmVmb3g3OFwiLCBcInNhZmFyaTEyXCJdLCAvLyBzZWUgZG9jcy9icm93c2VyLXN1cHBvcnQubWRcbiAgICAgICAgICAgIHJvbGx1cE9wdGlvbnM6IHtcbiAgICAgICAgICAgICAgICBpbnB1dDoge1xuICAgICAgICAgICAgICAgICAgICBbZW50cnlwb2ludEluZm8ub3V0TmFtZV06IGVudHJ5cG9pbnRJbmZvLmVudHJ5UG9pbnRGaWxlLFxuICAgICAgICAgICAgICAgIH0sXG4gICAgICAgICAgICAgICAgb3V0cHV0OiB7XG4gICAgICAgICAgICAgICAgICAgIGFzc2V0RmlsZU5hbWVzOiBgJHtlbnRyeXBvaW50SW5mby5vdXROYW1lfS5jc3NgLFxuICAgICAgICAgICAgICAgICAgICBlbnRyeUZpbGVOYW1lczogYCR7ZW50cnlwb2ludEluZm8ub3V0TmFtZX0ubWpzYCxcbiAgICAgICAgICAgICAgICB9LFxuICAgICAgICAgICAgfSxcbiAgICAgICAgfSxcbiAgICAgICAgcGx1Z2luczogW1xuICAgICAgICAgICAgcGx1Z2luUmVhY3Qoe1xuICAgICAgICAgICAgICAgIGJhYmVsOiB7XG4gICAgICAgICAgICAgICAgICAgIHBhcnNlck9wdHM6IHtcbiAgICAgICAgICAgICAgICAgICAgICAgIHBsdWdpbnM6IFtcImRlY29yYXRvcnMtbGVnYWN5XCJdLCAvLyBuZWVkZWQgc28gbW9ieCBkZWNvcmF0b3JzIHdvcmsgY29ycmVjdGx5XG4gICAgICAgICAgICAgICAgICAgIH0sXG4gICAgICAgICAgICAgICAgfSxcbiAgICAgICAgICAgIH0pLFxuICAgICAgICAgICAgcGx1Z2luQ2hlY2tlcih7XG4gICAgICAgICAgICAgICAgdHlwZXNjcmlwdDoge1xuICAgICAgICAgICAgICAgICAgICBidWlsZE1vZGU6IHRydWUsXG4gICAgICAgICAgICAgICAgICAgIHRzY29uZmlnUGF0aDogXCJ0c2NvbmZpZy52aXRlLWNoZWNrZXIuanNvblwiLFxuICAgICAgICAgICAgICAgIH0sXG4gICAgICAgICAgICB9KSxcbiAgICAgICAgXSxcbiAgICAgICAgc2VydmVyOiB7XG4gICAgICAgICAgICBwb3J0OiA4MDkwLFxuICAgICAgICAgICAgd2FybXVwOiB7IGNsaWVudEZpbGVzOiBbVklURV9BU1NFVF9TSVRFX0VOVFJZXSB9LFxuICAgICAgICB9LFxuICAgICAgICBwcmV2aWV3OiB7XG4gICAgICAgICAgICBwb3J0OiA4MDkwLFxuICAgICAgICB9LFxuICAgIH0pXG59XG4iLCAiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlclwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL3NvcGhpYS9jb2RlL293aWQvb3dpZC1ncmFwaGVyL3ZpdGUuY29uZmlnLXNpdGUubXRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9zb3BoaWEvY29kZS9vd2lkL293aWQtZ3JhcGhlci92aXRlLmNvbmZpZy1zaXRlLm10c1wiO2ltcG9ydCB7IFZpdGVFbnRyeVBvaW50IH0gZnJvbSBcIi4vc2l0ZS92aXRlVXRpbHMudHN4XCJcbmltcG9ydCB7IGRlZmluZVZpdGVDb25maWdGb3JFbnRyeXBvaW50IH0gZnJvbSBcIi4vdml0ZS5jb25maWctY29tbW9uLm10c1wiXG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZVZpdGVDb25maWdGb3JFbnRyeXBvaW50KFZpdGVFbnRyeVBvaW50LlNpdGUpXG4iXSwKICAibWFwcGluZ3MiOiAiOzs7Ozs7O0FBQUEsT0FBTyxXQUFXOzs7QUNBdVMsT0FBTyxVQUFVO0FBQzFVLE9BQU8sUUFBUTtBQVFBLFNBQVIsbUJBQW9DLE1BQWtDO0FBQ3pFLE1BQUksQ0FBQyxHQUFHLFdBQVksUUFBTztBQUUzQixNQUFJLE1BQU0sS0FBSyxRQUFRLElBQUk7QUFFM0IsU0FBTyxJQUFJLFFBQVE7QUFDZixRQUFJLEdBQUcsV0FBVyxLQUFLLFFBQVEsS0FBSyxjQUFjLENBQUMsRUFBRyxRQUFPO0FBRTdELFVBQU0sWUFBWSxLQUFLLFFBQVEsS0FBSyxJQUFJO0FBRXhDLFFBQUksY0FBYyxJQUFLO0FBQUEsUUFDbEIsT0FBTTtBQUFBLEVBQ2Y7QUFFQSxTQUFPO0FBQ1g7OztBRHRCQSxPQUFPQSxTQUFROzs7QUVDZixPQUFPQyxXQUFV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QVE7QUFBQSxNQUNKLE1BQU07QUFBQSxNQUNOLFFBQVEsRUFBRSxhQUFhLENBQUMscUJBQXFCLEVBQUU7QUFBQSxJQUNuRDtBQUFBLElBQ0EsU0FBUztBQUFBLE1BQ0wsTUFBTTtBQUFBLElBQ1Y7QUFBQSxFQUNKLENBQUM7QUFDTDs7O0FDakZBLElBQU8sMkJBQVEsK0NBQWlEOyIsCiAgIm5hbWVzIjogWyJmcyIsICJwYXRoIiwgImRvdGVudiIsICJmcyIsICJfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSIsICJfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSIsICJiYXNlRGlyIiwgIkZlYXR1cmVGbGFnRmVhdHVyZSIsICJwYXJzZUludE9yVW5kZWZpbmVkIiwgIl9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lIiwgIl9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lIiwgImRvdGVudiIsICJCQUtFRF9CQVNFX1VSTCIsICJBRE1JTl9CQVNFX1VSTCIsICJCQUtFRF9HUkFQSEVSX1VSTCIsICJCVUdTTkFHX0FQSV9LRVkiLCAicGFyc2VJbnRPclVuZGVmaW5lZCIsICJwYXRoIiwgIkdET0NTX0RFVEFJTFNfT05fREVNQU5EX0lEIiwgInBhdGgiLCAiZnMiLCAiSU1BR0VfSE9TVElOR19SMl9DRE5fVVJMIiwgIklNQUdFX0hPU1RJTkdfUjJfQlVDS0VUX1BBVEgiLCAiSU1BR0VfSE9TVElOR19SMl9CVUNLRVRfU1VCRk9MREVSX1BBVEgiLCAiQURNSU5fQkFTRV9VUkwiXQp9Cg==