1/*! 2 * Matomo - free/libre analytics platform 3 * 4 * @link https://matomo.org 5 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later 6 */ 7 8import { 9 reactive, 10 watch, 11 computed, 12 readonly, 13} from 'vue'; 14import MatomoUrl from '../MatomoUrl/MatomoUrl'; 15import Matomo from '../Matomo/Matomo'; 16import translate from '../translate'; 17import Periods from '../Periods/Periods'; 18import AjaxHelper from '../AjaxHelper/AjaxHelper'; 19import SegmentsStore from '../Segmentation/Segments.store'; 20 21const SERIES_COLOR_COUNT = 8; 22const SERIES_SHADE_COUNT = 3; 23 24export interface SegmentComparison { 25 params: { 26 segment: string, 27 }, 28 title: string, 29 index: number, 30} 31 32export interface PeriodComparison { 33 params: { 34 period: string, 35 date: string, 36 }, 37 title: string, 38 index: number, 39} 40 41export interface AnyComparison { 42 params: { [name: string]: string }, 43 title: string, 44 index: number, 45} 46 47export interface ComparisonsStoreState { 48 comparisonsDisabledFor: string[]; 49} 50 51export interface ComparisonSeriesInfo { 52 index: number; 53 params: { [key: string]: string }; 54 color: string; 55} 56 57function wrapArray<T>(values: T | T[]): T[] { 58 if (!values) { 59 return []; 60 } 61 return values instanceof Array ? values : [values]; 62} 63 64export default class ComparisonsStore { 65 private privateState = reactive<ComparisonsStoreState>({ 66 comparisonsDisabledFor: [], 67 }); 68 69 readonly state = readonly(this.privateState); // for tests 70 71 private colors: { [key: string]: string } = {}; 72 73 readonly segmentComparisons = computed(() => this.parseSegmentComparisons()); 74 75 readonly periodComparisons = computed(() => this.parsePeriodComparisons()); 76 77 readonly isEnabled = computed(() => this.checkEnabledForCurrentPage()); 78 79 constructor() { 80 this.loadComparisonsDisabledFor(); 81 82 $(() => { 83 this.colors = this.getAllSeriesColors() as { [key: string]: string }; 84 }); 85 86 watch( 87 () => this.getComparisons(), 88 () => Matomo.postEvent('piwikComparisonsChanged'), 89 { deep: true }, 90 ); 91 } 92 93 getComparisons(): AnyComparison[] { 94 return (this.getSegmentComparisons() as AnyComparison[]) 95 .concat(this.getPeriodComparisons() as AnyComparison[]); 96 } 97 98 isComparing(): boolean { 99 return this.isComparisonEnabled() 100 // first two in each array are for the currently selected segment/period 101 && (this.segmentComparisons.value.length > 1 102 || this.periodComparisons.value.length > 1); 103 } 104 105 isComparingPeriods(): boolean { 106 return this.getPeriodComparisons().length > 1; // first is currently selected period 107 } 108 109 getSegmentComparisons(): SegmentComparison[] { 110 if (!this.isComparisonEnabled()) { 111 return []; 112 } 113 114 return this.segmentComparisons.value; 115 } 116 117 getPeriodComparisons(): PeriodComparison[] { 118 if (!this.isComparisonEnabled()) { 119 return []; 120 } 121 122 return this.periodComparisons.value; 123 } 124 125 getSeriesColor( 126 segmentComparison: SegmentComparison, 127 periodComparison: PeriodComparison, 128 metricIndex = 0, 129 ): string { 130 const seriesIndex = this.getComparisonSeriesIndex( 131 periodComparison.index, 132 segmentComparison.index, 133 ) % SERIES_COLOR_COUNT; 134 135 if (metricIndex === 0) { 136 return this.colors[`series${seriesIndex}`]; 137 } 138 139 const shadeIndex = metricIndex % SERIES_SHADE_COUNT; 140 return this.colors[`series${seriesIndex}-shade${shadeIndex}`]; 141 } 142 143 getSeriesColorName(seriesIndex: number, metricIndex: number): string { 144 let colorName = `series${(seriesIndex % SERIES_COLOR_COUNT)}`; 145 if (metricIndex > 0) { 146 colorName += `-shade${(metricIndex % SERIES_SHADE_COUNT)}`; 147 } 148 return colorName; 149 } 150 151 isComparisonEnabled(): boolean { 152 return this.isEnabled.value; 153 } 154 155 getIndividualComparisonRowIndices(seriesIndex: number): { 156 segmentIndex: number, 157 periodIndex: number, 158 } { 159 const segmentCount = this.getSegmentComparisons().length; 160 const segmentIndex = seriesIndex % segmentCount; 161 const periodIndex = Math.floor(seriesIndex / segmentCount); 162 163 return { 164 segmentIndex, 165 periodIndex, 166 }; 167 } 168 169 getComparisonSeriesIndex(periodIndex: number, segmentIndex: number): number { 170 const segmentCount = this.getSegmentComparisons().length; 171 return periodIndex * segmentCount + segmentIndex; 172 } 173 174 getAllComparisonSeries(): ComparisonSeriesInfo[] { 175 const seriesInfo: ComparisonSeriesInfo[] = []; 176 177 let seriesIndex = 0; 178 this.getPeriodComparisons().forEach((periodComp) => { 179 this.getSegmentComparisons().forEach((segmentComp) => { 180 seriesInfo.push({ 181 index: seriesIndex, 182 params: { ...segmentComp.params, ...periodComp.params }, 183 color: this.colors[`series${seriesIndex}`], 184 }); 185 seriesIndex += 1; 186 }); 187 }); 188 189 return seriesInfo; 190 } 191 192 removeSegmentComparison(index: number): void { 193 if (!this.isComparisonEnabled()) { 194 throw new Error('Comparison disabled.'); 195 } 196 197 const newComparisons: SegmentComparison[] = [...this.segmentComparisons.value]; 198 newComparisons.splice(index, 1); 199 200 const extraParams: {[key: string]: string} = {}; 201 if (index === 0) { 202 extraParams.segment = newComparisons[0].params.segment; 203 } 204 205 this.updateQueryParamsFromComparisons( 206 newComparisons, 207 this.periodComparisons.value, 208 extraParams, 209 ); 210 } 211 212 addSegmentComparison(params: { [name: string]: string }): void { 213 if (!this.isComparisonEnabled()) { 214 throw new Error('Comparison disabled.'); 215 } 216 217 const newComparisons = this.segmentComparisons.value 218 .concat([{ params, index: -1, title: '' } as SegmentComparison]); 219 this.updateQueryParamsFromComparisons(newComparisons, this.periodComparisons.value); 220 } 221 222 private updateQueryParamsFromComparisons( 223 segmentComparisons: SegmentComparison[], 224 periodComparisons: PeriodComparison[], 225 extraParams = {}, 226 ) { 227 // get unique segments/periods/dates from new Comparisons 228 const compareSegments: {[key: string]: boolean} = {}; 229 const comparePeriodDatePairs: {[key: string]: boolean} = {}; 230 231 let firstSegment = false; 232 let firstPeriod = false; 233 234 segmentComparisons.forEach((comparison) => { 235 if (firstSegment) { 236 compareSegments[comparison.params.segment] = true; 237 } else { 238 firstSegment = true; 239 } 240 }); 241 242 periodComparisons.forEach((comparison) => { 243 if (firstPeriod) { 244 comparePeriodDatePairs[`${comparison.params.period}|${comparison.params.date}`] = true; 245 } else { 246 firstPeriod = true; 247 } 248 }); 249 250 const comparePeriods: string[] = []; 251 const compareDates: string[] = []; 252 Object.keys(comparePeriodDatePairs).forEach((pair) => { 253 const parts = pair.split('|'); 254 comparePeriods.push(parts[0]); 255 compareDates.push(parts[1]); 256 }); 257 258 const compareParams: {[key: string]: string[]} = { 259 compareSegments: Object.keys(compareSegments), 260 comparePeriods, 261 compareDates, 262 }; 263 264 // change the page w/ these new param values 265 if (Matomo.helper.isAngularRenderingThePage()) { 266 const search = MatomoUrl.hashParsed.value; 267 268 const newSearch: {[key: string]: string|string[]} = { 269 ...search, 270 ...compareParams, 271 ...extraParams, 272 }; 273 274 delete newSearch['compareSegments[]']; 275 delete newSearch['comparePeriods[]']; 276 delete newSearch['compareDates[]']; 277 278 if (JSON.stringify(newSearch) !== JSON.stringify(search)) { 279 MatomoUrl.updateHash(newSearch); 280 } 281 282 return; 283 } 284 285 const paramsToRemove: string[] = []; 286 ['compareSegments', 'comparePeriods', 'compareDates'].forEach((name) => { 287 if (!compareParams[name].length) { 288 paramsToRemove.push(name); 289 } 290 }); 291 292 // angular is not rendering the page (ie, we are in the embedded dashboard) or we need to change 293 // the segment 294 const url = MatomoUrl.stringify(extraParams); 295 const strHash = MatomoUrl.stringify(compareParams); 296 297 window.broadcast.propagateNewPage(url, undefined, strHash, paramsToRemove); 298 } 299 300 private getAllSeriesColors() { 301 const { ColorManager } = Matomo; 302 if (!ColorManager) { 303 return []; 304 } 305 306 const seriesColorNames = []; 307 308 for (let i = 0; i < SERIES_COLOR_COUNT; i += 1) { 309 seriesColorNames.push(`series${i}`); 310 for (let j = 0; j < SERIES_SHADE_COUNT; j += 1) { 311 seriesColorNames.push(`series${i}-shade${j}`); 312 } 313 } 314 315 return ColorManager.getColors('comparison-series-color', seriesColorNames); 316 } 317 318 private loadComparisonsDisabledFor() { 319 const matomoModule: string = MatomoUrl.parsed.value.module as string; 320 if (matomoModule === 'CoreUpdater' 321 || matomoModule === 'Installation' 322 ) { 323 this.privateState.comparisonsDisabledFor = []; 324 return; 325 } 326 327 AjaxHelper.fetch({ 328 module: 'API', 329 method: 'API.getPagesComparisonsDisabledFor', 330 }).then((result) => { 331 this.privateState.comparisonsDisabledFor = result; 332 }); 333 } 334 335 private parseSegmentComparisons(): SegmentComparison[] { 336 const { availableSegments } = SegmentsStore.state; 337 338 const compareSegments: string[] = [ 339 ...wrapArray(MatomoUrl.parsed.value.compareSegments as string[]), 340 ]; 341 342 // add base comparisons 343 compareSegments.unshift(MatomoUrl.parsed.value.segment as string || ''); 344 345 const newSegmentComparisons: SegmentComparison[] = []; 346 compareSegments.forEach((segment, idx) => { 347 let storedSegment!: { definition: string, name: string }; 348 349 availableSegments.forEach((s) => { 350 if (s.definition === segment 351 || s.definition === decodeURIComponent(segment) 352 || decodeURIComponent(s.definition) === segment 353 ) { 354 storedSegment = s; 355 } 356 }); 357 358 let segmentTitle = storedSegment ? storedSegment.name : translate('General_Unknown'); 359 if (segment.trim() === '') { 360 segmentTitle = translate('SegmentEditor_DefaultAllVisits'); 361 } 362 363 newSegmentComparisons.push({ 364 params: { 365 segment, 366 }, 367 title: Matomo.helper.htmlDecode(segmentTitle), 368 index: idx, 369 }); 370 }); 371 372 return newSegmentComparisons; 373 } 374 375 private parsePeriodComparisons(): PeriodComparison[] { 376 const comparePeriods: string[] = [ 377 ...wrapArray(MatomoUrl.parsed.value.comparePeriods as string[]), 378 ]; 379 380 const compareDates: string[] = [ 381 ...wrapArray(MatomoUrl.parsed.value.compareDates as string[]), 382 ]; 383 384 comparePeriods.unshift(MatomoUrl.parsed.value.period as string); 385 compareDates.unshift(MatomoUrl.parsed.value.date as string); 386 387 const newPeriodComparisons: PeriodComparison[] = []; 388 for (let i = 0; i < Math.min(compareDates.length, comparePeriods.length); i += 1) { 389 let title; 390 try { 391 title = Periods.parse(comparePeriods[i], compareDates[i]).getPrettyString(); 392 } catch (e) { 393 title = translate('General_Error'); 394 } 395 396 newPeriodComparisons.push({ 397 params: { 398 date: compareDates[i], 399 period: comparePeriods[i], 400 }, 401 title, 402 index: i, 403 }); 404 } 405 406 return newPeriodComparisons; 407 } 408 409 private checkEnabledForCurrentPage() { 410 // category/subcategory is not included on top bar pages, so in that case we use module/action 411 const category = MatomoUrl.parsed.value.category || MatomoUrl.parsed.value.module; 412 const subcategory = MatomoUrl.parsed.value.subcategory 413 || MatomoUrl.parsed.value.action; 414 415 const id = `${category}.${subcategory}`; 416 const isEnabled = this.privateState.comparisonsDisabledFor.indexOf(id) === -1 417 && this.privateState.comparisonsDisabledFor.indexOf(`${category}.*`) === -1; 418 419 document.documentElement.classList.toggle('comparisonsDisabled', !isEnabled); 420 421 return isEnabled; 422 } 423} 424