PageSourceSearch

https://aim-review-app.web.app/oop/agreementClass.js?v=20260906-doi-clean

js aim-review-app.web.app collected 2026-10-03 12:16:21 UTC 34,865 bytes, 935 lines download raw bytes

1class AGREEMENT_DATA{
2  constructor(status_id){
3    this.status_id = status_id;
4    this.label_array = [];
5    this.selected_label_array = [];
6    this.paper_id_array = [];
7    this.raters_identifiers = [];
8    this.data_array = [];
9
10    // Overall Metrics.
11    this.agreement_percentage = [];
12    this.fleiss_kappa_stats = [];
13    this.fleiss_kappa_not_calculable_reason = null;
14
15    // Agreement ofr individual docs.
16    this.individual_agreement_array = [];
17    this.individual_agreement_colours = [];
18
19    // Raters correlation.
20    this.corr_matrix_stats = [];
21    this.t_matrix_stats = [];
22    this.p_matrix_stats = [];
23    this.matrix_colours = [];
24
25    // Miscellaneous.
26    this.list_of_colours = [
27      "#4CAF50", // Green
28      "#42A5F5", // Blue
29      "#FF5252", // Red
30      "#FFD740", // Yellow
31      "#FF4081", // Pink
32      "#8BC34A", // Light Green
33      "#FF9800", // Orange
34      "#9C27B0", // Purple
35      "#795548", // Brown
36      "#00BCD4", // Cyan
37      "#FF5722", // Deep Orange
38      "#3F51B5", // Indigo
39      "#9E9E9E", // Grey
40      "#607D8B", // Blue Grey
41      "#F44336", // Dark Red
42      "#2196F3", // Dark Blue
43      "#00ACC1", // Dark Cyan
44      "#9C27B0"  // Dark Purple
45    ];
46
47    //Resolution labelling.
48    this.currentIndex = 0;
49    this.indexes_to_label = [];
50    this.view_state = {};
51    this._save_meta = null;
52
53  }
54
55  _normalize_binary_label(value) {
56    if (value === 0 || value === "0" || value === false || value === "false") return 0;
57    if (value === 1 || value === "1" || value === true || value === "true") return 1;
58    return null;
59  }
60
61  _normalize_selected_flag(value, fallback = true) {
62    if (value == null || value === "") return fallback;
63    if (value === true || value === 1 || value === "1") return true;
64    if (value === false || value === 0 || value === "0") return false;
65
66    const normalized = String(value).trim().toLowerCase();
67    if (["true", "yes", "y", "selected"].includes(normalized)) return true;
68    if (["false", "no", "n", "unselected"].includes(normalized)) return false;
69    return fallback;
70  }
71
72  _normalize_rater_identifier(identifier, fallbackIndex = 0) {
73    const normalized = String(identifier ?? "").trim();
74    return normalized || `Rater ${fallbackIndex + 1}`;
75  }
76
77  _normalize_paper_id_key(paperId, fallback = "") {
78    const utils = globalThis?.AIMReviewIdentifierUtils;
79    const normalized = utils?.getPaperIdentifierMatchKey
80      ? utils.getPaperIdentifierMatchKey(paperId)
81      : String(paperId ?? "").trim();
82    return normalized || fallback;
83  }
84
85  _calculate_agreement_for_labels(labels) {
86    const validLabels = (Array.isArray(labels) ? labels : [])
87      .map(label => this._normalize_binary_label(label))
88      .filter(label => label === 0 || label === 1);
89
90    if (validLabels.length < 2) return null;
91
92    const labelCounts = {};
93    validLabels.forEach(label => {
94      labelCounts[label] = (labelCounts[label] || 0) + 1;
95    });
96
97    const agreementCount = Math.max(...Object.values(labelCounts));
98    const agreement = 100 * agreementCount / validLabels.length;
99    return Number.isFinite(agreement) ? agreement : null;
100  }
101
102  _get_individual_agreement_for_entry_index(index) {
103    const row = Array.isArray(this.label_array?.[index]) ? this.label_array[index] : [];
104    return this._calculate_agreement_for_labels(row);
105  }
106
107  get_individual_agreement_for_position(position) {
108    const normalizedPosition = Math.max(0, Math.floor(Number(position) || 0));
109    const entryIndex = Array.isArray(this.indexes_to_label)
110      ? this.indexes_to_label[normalizedPosition]
111      : null;
112    return this._get_individual_agreement_for_entry_index(entryIndex);
113  }
114
115  _is_agreement_results_metadata_header(header) {
116    const normalized = String(header ?? "").trim().toLowerCase();
117    const compact = normalized.replace(/[^a-z0-9%]+/g, "");
118    return [
119      "",
120      "paperid",
121      "sheet",
122      "sourcefile",
123      "filename",
124      "file",
125      "individualagreement",
126      "individualagreement%"
127    ].includes(compact);
128  }
129
130  _get_previous_agreement_rater_headers(labelData = []) {
131    const headers = Object.keys(labelData?.[0] || {});
132    const agreementColumnIndex = headers.findIndex(header =>
133      String(header ?? "").trim().toLowerCase() === "individual agreement (%)"
134    );
135    const orderedHeaders = agreementColumnIndex >= 0
136      ? headers.slice(0, agreementColumnIndex)
137      : headers;
138
139    const candidateHeaders = orderedHeaders.filter(header =>
140      !this._is_agreement_results_metadata_header(header)
141    );
142    const headersWithLabels = candidateHeaders.filter(header =>
143      labelData.some(row => {
144        const label = this._normalize_binary_label(row?.[header]);
145        return label === 0 || label === 1;
146      })
147    );
148
149    return headersWithLabels.length >= 2 ? headersWithLabels : candidateHeaders;
150  }
151
152  _register_rater_identifier(identifier) {
153    const normalized = this._normalize_rater_identifier(identifier, this.raters_identifiers.length);
154    const existingIndex = this.raters_identifiers.indexOf(normalized);
155    if (existingIndex !== -1) {
156      return existingIndex;
157    }
158
159    this.raters_identifiers.push(normalized);
160    this.label_array = (Array.isArray(this.label_array) ? this.label_array : []).map(row => {
161      const padded = Array.isArray(row) ? [...row] : [];
162      while (padded.length < this.raters_identifiers.length) {
163        padded.push(null);
164      }
165      return padded;
166    });
167
168    return this.raters_identifiers.length - 1;
169  }
170
171  _pad_label_rows() {
172    const width = Array.isArray(this.raters_identifiers) ? this.raters_identifiers.length : 0;
173    this.label_array = (Array.isArray(this.label_array) ? this.label_array : []).map(row => {
174      const padded = Array.isArray(row) ? [...row] : [];
175      while (padded.length < width) {
176        padded.push(null);
177      }
178      return padded.slice(0, width);
179    });
180  }
181
182  _get_valid_labels_for_index(index) {
183    const row = Array.isArray(this.label_array?.[index]) ? this.label_array[index] : [];
184    return row.filter(label => label === 0 || label === 1);
185  }
186
187  _get_comparable_indexes() {
188    return (Array.isArray(this.data_array) ? this.data_array : [])
189      .map((entry, index) => this._get_valid_labels_for_index(index).length >= 2 ? index : -1)
190      .filter(index => index !== -1);
191  }
192
193  _build_pairwise_arrays(firstRaterIndex, secondRaterIndex) {
194    const first = [];
195    const second = [];
196
197    (Array.isArray(this.indexes_to_label) ? this.indexes_to_label : []).forEach(index => {
198      const row = Array.isArray(this.label_array?.[index]) ? this.label_array[index] : [];
199      const firstLabel = this._normalize_binary_label(row[firstRaterIndex]);
200      const secondLabel = this._normalize_binary_label(row[secondRaterIndex]);
201      if ((firstLabel === 0 || firstLabel === 1) && (secondLabel === 0 || secondLabel === 1)) {
202        first.push(firstLabel);
203        second.push(secondLabel);
204      }
205    });
206
207    return [first, second];
208  }
209
210  _sanitize_metric_tuple(values, length = 3) {
211    const tuple = Array.isArray(values) ? values.slice(0, length) : [];
212    while (tuple.length < length) {
213      tuple.push(null);
214    }
215    return tuple.map(value => {
216      const number = Number(value);
217      return Number.isFinite(number) ? number : null;
218    });
219  }
220
221  _get_paper_id_array() {
222    return (Array.isArray(this.data_array) ? this.data_array : []).map(entry => entry?.paper_id ?? null);
223  }
224
225  _get_selected_label_array() {
226    return (Array.isArray(this.indexes_to_label) ? this.indexes_to_label : [])
227      .map(index => this._get_valid_labels_for_index(index))
228      .filter(row => Array.isArray(row) && row.length >= 2);
229  }
230
231  _has_missing_labels_for_fleiss() {
232    const raterCount = Array.isArray(this.raters_identifiers) ? this.raters_identifiers.length : 0;
233    if (raterCount < 2) return true;
234
235    return (Array.isArray(this.label_array) ? this.label_array : []).some(row => {
236      const labels = Array.isArray(row) ? row.slice(0, raterCount) : [];
237      while (labels.length < raterCount) {
238        labels.push(null);
239      }
240      const validCount = labels.filter(label => label === 0 || label === 1).length;
241      return validCount > 0 && validCount < raterCount;
242    });
243  }
244
245  _sync_derived_arrays() {
246    this.paper_id_array = this._get_paper_id_array();
247    this._pad_label_rows();
248    this.indexes_to_label = this._get_comparable_indexes();
249    this.selected_label_array = this._get_selected_label_array();
250  }
251
252  import_data_from_storage_json(json_data) {
253    if (!json_data || !Array.isArray(json_data.data_array) || !Array.isArray(json_data.label_array)) {
254      throw new Error("Invalid agreement progress data.");
255    }
256
257    if (!Array.isArray(json_data.raters_identifiers) || json_data.raters_identifiers.length < 2) {
258      throw new Error("Agreement progress must include at least two raters.");
259    }
260
261    if (json_data.label_array.length !== json_data.data_array.length) {
262      throw new Error("Agreement progress entries and labels do not match.");
263    }
264
265    const raters = json_data.raters_identifiers.map((identifier, index) =>
266      this._normalize_rater_identifier(identifier, index)
267    );
268
269    const label_array = json_data.label_array.map(row => {
270      if (!Array.isArray(row)) {
271        throw new Error("Agreement progress label matrix is corrupted.");
272      }
273
274      const normalizedRow = row.map(label => this._normalize_binary_label(label));
275      while (normalizedRow.length < raters.length) {
276        normalizedRow.push(null);
277      }
278      return normalizedRow.slice(0, raters.length);
279    });
280
281    const data_array = json_data.data_array.map(entry => ({ ...entry }));
282
283    this.status_id = json_data.status_id || this.status_id;
284    this.data_array = data_array;
285    this.raters_identifiers = raters;
286    this.label_array = label_array;
287    this._sync_derived_arrays();
288    this.currentIndex = Math.min(
289      Math.max(Number.isInteger(Number(json_data.currentIndex)) ? Number(json_data.currentIndex) : 0, 0),
290      Math.max(this.indexes_to_label.length - 1, 0)
291    );
292
293    this.agreement_percentage = [];
294    this.fleiss_kappa_stats = [];
295    this.fleiss_kappa_not_calculable_reason = null;
296    this.individual_agreement_array = [];
297    this.individual_agreement_colours = [];
298    this.corr_matrix_stats = [];
299    this.t_matrix_stats = [];
300    this.p_matrix_stats = [];
301    this.matrix_colours = [];
302    this.view_state = json_data.view_state && typeof json_data.view_state === "object"
303      ? JSON.parse(JSON.stringify(json_data.view_state))
304      : {};
305    this._save_meta = json_data._save_meta && typeof json_data._save_meta === "object"
306      ? JSON.parse(JSON.stringify(json_data._save_meta))
307      : null;
308  }
309
310  export_snapshot_state() {
311    return {
312      label_array: this.label_array,
313      selected_label_array: [],
314      paper_id_array: [],
315      raters_identifiers: this.raters_identifiers,
316      data_array: this.data_array,
317      agreement_percentage: this.agreement_percentage,
318      fleiss_kappa_stats: this.fleiss_kappa_stats,
319      fleiss_kappa_not_calculable_reason: this.fleiss_kappa_not_calculable_reason,
320      individual_agreement_array: this.individual_agreement_array,
321      individual_agreement_colours: this.individual_agreement_colours,
322      corr_matrix_stats: this.corr_matrix_stats,
323      t_matrix_stats: this.t_matrix_stats,
324      p_matrix_stats: this.p_matrix_stats,
325      matrix_colours: this.matrix_colours,
326      currentIndex: this.currentIndex,
327      indexes_to_label: this.indexes_to_label,
328      view_state: this.view_state,
329      _save_meta: this._save_meta
330    };
331  }
332
333  import_snapshot_state(state) {
334    this.import_data_from_storage_json(state);
335
336    this.selected_label_array = Array.isArray(state.selected_label_array) &&
337      state.selected_label_array.length === this.indexes_to_label.length
338      ? state.selected_label_array
339      : this._get_selected_label_array();
340    this.agreement_percentage = Array.isArray(state.agreement_percentage) ? state.agreement_percentage : [];
341    this.fleiss_kappa_stats = Array.isArray(state.fleiss_kappa_stats) ? state.fleiss_kappa_stats : [];
342    this.fleiss_kappa_not_calculable_reason = typeof state.fleiss_kappa_not_calculable_reason === "string"
343      ? state.fleiss_kappa_not_calculable_reason
344      : null;
345    this.individual_agreement_array = Array.isArray(state.individual_agreement_array) ? state.individual_agreement_array : [];
346    this.individual_agreement_colours = Array.isArray(state.individual_agreement_colours) ? state.individual_agreement_colours : [];
347    this.corr_matrix_stats = Array.isArray(state.corr_matrix_stats) ? state.corr_matrix_stats : [];
348    this.t_matrix_stats = Array.isArray(state.t_matrix_stats) ? state.t_matrix_stats : [];
349    this.p_matrix_stats = Array.isArray(state.p_matrix_stats) ? state.p_matrix_stats : [];
350    this.matrix_colours = Array.isArray(state.matrix_colours) ? state.matrix_colours : [];
351  }
352
353  // Function to extract labels for each paper_id
354  add_data(data, raterIdentifier = null) {
355    const paperIdToIndex = new Map(this.paper_id_array.map((paperId, index) => [
356      this._normalize_paper_id_key(paperId, `__missing_existing_${index}`),
357      index
358    ]));
359
360    for (var i = 0; i < data.length; i++) {
361      const {paper_id, label, selected_for_training} = data[i];
362      const paperIdKey = this._normalize_paper_id_key(paper_id, `__missing_import_${i}`);
363      const normalizedLabel = this._normalize_binary_label(label);
364      const resolvedRaterIdentifier = raterIdentifier || data[i]?.rater_identifier || data[i]?.source_file || data[i]?._sheet;
365      const raterIndex = this._register_rater_identifier(resolvedRaterIdentifier);
366
367      if (!paperIdToIndex.has(paperIdKey)) {
368        this.data_array.push({...data[i], label: null});
369        this.paper_id_array.push(paper_id);
370        paperIdToIndex.set(paperIdKey, this.paper_id_array.length - 1);
371        const labelRow = new Array(this.raters_identifiers.length).fill(null);
372        labelRow[raterIndex] = normalizedLabel;
373        this.label_array.push(labelRow);
374
375      } else {
376        const index = paperIdToIndex.get(paperIdKey);
377        const labelRow = Array.isArray(this.label_array[index])
378          ? this.label_array[index]
379          : new Array(this.raters_identifiers.length).fill(null);
380
381        while (labelRow.length < this.raters_identifiers.length) {
382          labelRow.push(null);
383        }
384
385        const existingLabel = this._normalize_binary_label(labelRow[raterIndex]);
386        if (existingLabel !== null && normalizedLabel !== null && existingLabel !== normalizedLabel) {
387          throw new Error(`Duplicate labels were found for ${this.raters_identifiers[raterIndex]} on ${paper_id || "an imported record"}.`);
388        }
389
390        labelRow[raterIndex] = normalizedLabel;
391        this.label_array[index] = labelRow;
392
393        const existingEntry = this.data_array[index] || {};
394        this.data_array[index] = {
395          ...data[i],
396          ...existingEntry,
397          label: existingEntry.label ?? null,
398          selected_for_training: existingEntry.selected_for_training ?? selected_for_training,
399          notes: existingEntry.notes ?? data[i]?.notes ?? ""
400        };
401      };
402    }
403    this._sync_derived_arrays();
404  };
405
406  add_raters_indentifiers(identifiers){
407    (Array.isArray(identifiers) ? identifiers : []).forEach(identifier => {
408      this._register_rater_identifier(identifier);
409    });
410    this._sync_derived_arrays();
411  }
412
413
414  // Check if number of labels are all the same for each publication.
415  check_dimensions(){
416    const width = Array.isArray(this.raters_identifiers) ? this.raters_identifiers.length : 0;
417    return (Array.isArray(this.label_array) ? this.label_array : []).some(row => !Array.isArray(row) || row.length !== width);
418  }
419
420  calculate_metrics(type, correction, alpha){
421    this._sync_derived_arrays();
422    const selectedLabels = this.selected_label_array;
423
424    if (!Array.isArray(selectedLabels) || selectedLabels.length === 0) {
425      throw new Error("No comparable agreement entries with at least two valid labels are available to analyse.");
426    }
427
428    this.individual_agreement_array = [];
429    this.individual_agreement_colours = [];
430    this.corr_matrix_stats = [];
431    this.t_matrix_stats = [];
432    this.p_matrix_stats = [];
433    this.agreement_percentage = percentageAgreement(selectedLabels);
434    if (this._has_missing_labels_for_fleiss()) {
435      this.fleiss_kappa_stats = [null, null, null, [null, null], null, null];
436      this.fleiss_kappa_not_calculable_reason = "Not calculable due to splitting of entries.";
437    } else {
438      this.fleiss_kappa_stats = fleiss_kappa(selectedLabels, alpha);
439      this.fleiss_kappa_not_calculable_reason = null;
440    }
441    this.set_individual_agreement_array();
442    this.set_document_agreement_colours();
443    this.calculate_matrix_metric(type, correction);
444  }
445
446  prepare_agreement_export(type = 1, correction = 1, alpha = 0.05) {
447    this._sync_derived_arrays();
448    const selectedLabels = this.selected_label_array;
449
450    this.agreement_percentage = Array.isArray(selectedLabels) && selectedLabels.length > 0
451      ? percentageAgreement(selectedLabels)
452      : null;
453
454    if (!Array.isArray(selectedLabels) || selectedLabels.length === 0 || this._has_missing_labels_for_fleiss()) {
455      this.fleiss_kappa_stats = [null, null, null, [null, null], null, null];
456      this.fleiss_kappa_not_calculable_reason = "Not calculable due to splitting of entries.";
457    } else {
458      this.fleiss_kappa_stats = fleiss_kappa(selectedLabels, alpha);
459      this.fleiss_kappa_not_calculable_reason = null;
460    }
461
462    this.set_individual_agreement_array();
463    this.set_document_agreement_colours();
464    this.calculate_matrix_metric(type, correction);
465  }
466
467  set_individual_agreement_array(){
468    this._sync_derived_arrays();
469    this.individual_agreement_array = (Array.isArray(this.indexes_to_label) ? this.indexes_to_label : [])
470      .map(index => this._get_individual_agreement_for_entry_index(index));
471  };
472
473
474  set_document_agreement_colours(){
475    this.individual_agreement_colours = [];
476    this.individual_agreement_array.forEach(agreement => {
477      const numericAgreement = Number(agreement);
478      if (!Number.isFinite(numericAgreement)) {
479        this.individual_agreement_colours.push("#9E9E9E");
480      } else if (numericAgreement >= 80) {
481        this.individual_agreement_colours.push("#4CAF50");
482      } else if (numericAgreement >= 60) {
483        this.individual_agreement_colours.push("#FFA500");
484      } else {
485        this.individual_agreement_colours.push("#FF5252");
486      }
487    });
488  };
489
490
491  calculate_matrix_metric(type, correction) {
492    let calc_function;
493    let correction_function;
494
495    // Selection of metric.
496    if (type == 1) {
497      calc_function = spearmanCoeff;
498    } else if (type == 2) {
499      calc_function = kendalltau;
500    } else if (type == 3) {
501      calc_function = cohen_kappa;
502    }
503    if (typeof calc_function !== "function") {
504      calc_function = spearmanCoeff;
505    }
506
507    // Selection of correction.
508    if (correction == 1) {
509      correction_function = fdrCorrection;
510    } else if (correction == 2) {
511      correction_function = bonferroniCorrection;
512    }
513    if (typeof correction_function !== "function") {
514      correction_function = pValue => pValue;
515    }
516
517    this._sync_derived_arrays();
518    const numRaters = Array.isArray(this.raters_identifiers) ? this.raters_identifiers.length : 0;
519    this.corr_matrix_stats = Array.from({ length: numRaters }, () => Array(numRaters).fill(null));
520    this.t_matrix_stats = Array.from({ length: numRaters }, () => Array(numRaters).fill(null));
521    this.p_matrix_stats = Array.from({ length: numRaters }, () => Array(numRaters).fill(null));
522
523    for (let i = 0; i < numRaters; i++) {
524      for (let j = i; j < numRaters; j++) {
525        if (i === j) {
526          this.corr_matrix_stats[i][j] = 1; // Diagonal elements are 1
527          this.t_matrix_stats[i][j] = 0;
528          this.p_matrix_stats[i][j] = 1; // p-value for correlation with itself is 1
529        } else {
530          const [firstLabels, secondLabels] = this._build_pairwise_arrays(i, j);
531          if (firstLabels.length < 2 || secondLabels.length < 2) {
532            continue;
533          }
534
535          const [correlation, t, pValue] = this._sanitize_metric_tuple(calc_function(firstLabels, secondLabels));
536          this.corr_matrix_stats[i][j] = correlation;
537          this.corr_matrix_stats[j][i] = correlation; // Since the matrix is symmetric
538          this.t_matrix_stats[i][j] = t;
539          this.t_matrix_stats[j][i] = t;
540          const correctedPValue = pValue == null ? null : correction_function(pValue, numRaters, j);
541          this.p_matrix_stats[i][j] = correctedPValue;
542          this.p_matrix_stats[j][i] = correctedPValue;
543        }
544      }
545    }
546  }
547
548
549
550  _build_agreement_results_aoa() {
551    this._sync_derived_arrays();
552    const headings = ["paper_id", ...deep_copy_array(this.raters_identifiers), "Individual agreement (%)"];
553    const paperIds = this._get_paper_id_array();
554    const rows = (Array.isArray(this.label_array) ? this.label_array : []).map((row, entryIndex) => {
555      const labels = (Array.isArray(this.raters_identifiers) ? this.raters_identifiers : []).map((_, raterIndex) => {
556        const normalizedLabel = this._normalize_binary_label(Array.isArray(row) ? row[raterIndex] : null);
557        return normalizedLabel === 0 || normalizedLabel === 1 ? normalizedLabel : null;
558      });
559      const agreement = this._calculate_agreement_for_labels(labels);
560      return [
561        paperIds[entryIndex] ?? "",
562        ...labels,
563        agreement == null ? null : agreement
564      ];
565    });
566
567    return [headings, ...rows];
568  }
569
570  save_agreement_results(filename, type = 1, correction = 1, alpha = 0.05) {
571    try {
572      this.prepare_agreement_export(type, correction, alpha);
573      const wb = XLSX.utils.book_new();
574
575      // ===== Sheet 1: Resolved Entries (ordered columns) =====
576      const orderedData = this.data_array.map(entry => ({
577        title: entry.title ?? "",
578        abstract: entry.abstract ?? "",
579        paper_id: entry.paper_id ?? "",
580        year: entry.year ?? "",
581        authors: entry.authors ?? "",
582        journal: entry.journal ?? "",
583        keywords: entry.keywords ?? "",
584        label: entry.label ?? "",
585        notes: entry.notes ?? "",
586        selected_for_training: entry.selected_for_training ?? ""
587      }));
588
589      const data_array_sheet = XLSX.utils.json_to_sheet(orderedData);
590      XLSX.utils.book_append_sheet(wb, data_array_sheet, 'Resolved Entries');
591
592      // ===== Sheet 2: Agreement Results =====
593      const ws = XLSX.utils.aoa_to_sheet([]);
594      const aoa = this._build_agreement_results_aoa();
595
596      XLSX.utils.sheet_add_aoa(ws, aoa);
597      XLSX.utils.book_append_sheet(wb, ws, 'Agreement Results');
598
599      // ===== Sheet 3: Agreement overall stats =====
600      const ws2 = XLSX.utils.aoa_to_sheet([
601        ['Fleiss k', 'Fleiss k SE', 'CI', 'z', 'p', 'Total agreement (%)'],
602        [
603          this.fleiss_kappa_stats[1],
604          this.fleiss_kappa_stats[2],
605          this.fleiss_kappa_stats[3],
606          this.fleiss_kappa_stats[4],
607          this.fleiss_kappa_stats[5],
608          this.agreement_percentage
609        ]
610      ]);
611      XLSX.utils.book_append_sheet(wb, ws2, 'Agreement overall stats');
612
613      // ===== Sheet 4–6 =====
614      XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(this.corr_matrix_stats), 'Correlation Stats');
615      XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(this.t_matrix_stats), 'T Values');
616      XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(this.p_matrix_stats), 'p Values');
617
618      XLSX.writeFile(wb, filename);
619
620    } catch (error) {
621      alert('⚠️ Length of text is longer than maximum possible in XLSX, falling back to CSV.');
622
623      const saveCsv = (content, name) => {
624        const blob = new Blob(['\uFEFF' + content], { type: 'text/csv;charset=utf-8;' });
625        const link = document.createElement('a');
626        const downloadUrl = URL.createObjectURL(blob);
627        link.href = downloadUrl;
628        link.download = name;
629        document.body.appendChild(link);
630        link.click();
631        document.body.removeChild(link);
632        setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
633      };
634
635      // ===== CSV fallback: Resolved Entries (same order) =====
636      const headers = [
637        'title','abstract','paper_id','year','authors',
638        'journal','keywords','label','notes','selected_for_training'
639      ];
640
641      const resolvedCSV = [
642        headers.join(','),
643        ...this.data_array.map(e =>
644          headers.map(h => `"${(e[h] ?? '').toString().replace(/"/g, '""')}"`).join(',')
645        )
646      ].join('\n');
647
648      saveCsv(resolvedCSV, 'Resolved_Entries.csv');
649
650      // Remaining CSV exports unchanged
651      const aoa = this._build_agreement_results_aoa();
652
653      saveCsv(aoa.map(r => r.join(',')).join('\n'), 'Agreement_Results.csv');
654
655      saveCsv(
656        [
657          ['Fleiss k','Fleiss k SE','CI','z','p','Total agreement (%)'].join(','),
658          [
659            this.fleiss_kappa_stats[1],
660            this.fleiss_kappa_stats[2],
661            this.fleiss_kappa_stats[3],
662            this.fleiss_kappa_stats[4],
663            this.fleiss_kappa_stats[5],
664            this.agreement_percentage
665          ].join(',')
666        ].join('\n'),
667        'Agreement_overall_stats.csv'
668      );
669
670      saveCsv(this.corr_matrix_stats.map(r => r.join(',')).join('\n'), 'Correlation_Stats.csv');
671      saveCsv(this.t_matrix_stats.map(r => r.join(',')).join('\n'), 'T_Values.csv');
672      saveCsv(this.p_matrix_stats.map(r => r.join(',')).join('\n'), 'P_Values.csv');
673    }
674  }
675
676
677
678  add_previous_agreement(data) {
679    const resolved_data = Array.isArray(data?.[0]) ? data[0] : [];  // Sheet 1: Resolved Entries
680    const label_data = Array.isArray(data?.[1]) ? data[1] : [];     // Sheet 2: Agreement Results
681
682    if (resolved_data.length === 0 || label_data.length === 0) {
683      throw new Error("Agreement spreadsheet files must include Resolved Entries and Agreement Results sheets.");
684    }
685
686    // --- 1. Add raters identifiers ---
687    // The loader adds bookkeeping fields such as _sheet/source_file; keep only real rater columns.
688    this.raters_identifiers = this._get_previous_agreement_rater_headers(label_data);
689    if (!Array.isArray(this.raters_identifiers) || this.raters_identifiers.length < 2) {
690      throw new Error("Agreement Results must include at least two rater label columns.");
691    }
692
693    // --- 2. Add paper ID array ---
694    this.paper_id_array = resolved_data.map(x => x.paper_id);
695
696    // --- 3. Add data array ---
697    // Copy all fields except loader bookkeeping metadata
698    this.data_array = resolved_data.map(({ _sheet, source_file, ...rest }) => ({ ...rest }));
699
700    // --- 4. Load label_array (rater labels only) ---
701    const labelsByPaperId = new Map(label_data.map((item, index) => [
702      this._normalize_paper_id_key(item?.paper_id, `__missing_${index}`),
703      item
704    ]));
705    this.label_array = this.paper_id_array.map((pid, index) => {
706      const entry = labelsByPaperId.get(this._normalize_paper_id_key(pid, `__missing_${index}`));
707      if (entry) {
708        // map rater columns to values
709        return this.raters_identifiers.map(rater => {
710          const val = entry[rater];
711          return val === undefined || val === null || val === '' ? null : this._normalize_binary_label(val);
712        });
713      } else {
714        return new Array(this.raters_identifiers.length).fill(null);
715      }
716    });
717
718    this._sync_derived_arrays();
719  };
720
721
722
723    doc_index_agreement_click(event, array, window){
724      let doc_data, percentage, rater_labels;
725      //Check if click is in one of the documents.
726
727      if (array.length != 0){
728        if (array[0]._datasetIndex == 0){
729          $('#'+window).modal('show');
730          //Change the index.
731          this.currentIndex = array[0]._index;
732        }
733      }
734    };
735
736    get_agreed_labels(preserveCurrentIndex = false) {
737      // Set labels where agreement is total, but only if label is missing
738      this.data_array.forEach((doc, index) => {
739        if (doc.label == null) { // only update if label is null or undefined
740          const labels = this._get_valid_labels_for_index(index);
741          if (labels.length < 2) return;
742          const allAgree = labels.every(label => label === labels[0]);
743          doc.label = allAgree ? labels[0] : null;
744        }
745      });
746
747      if (preserveCurrentIndex) {
748        this.currentIndex = Math.min(this.currentIndex, Math.max(this.indexes_to_label.length - 1, 0));
749        return;
750      }
751
752      // Find the first index in indexes_to_label where label is null
753      const firstNullIndex = this.indexes_to_label
754        .map(i => this.data_array[i])
755        .findIndex(doc => doc.label == null);
756
757      // If a null label is found, update currentIndex; otherwise, keep its original value
758      if (firstNullIndex !== -1) {
759        this.currentIndex = firstNullIndex;
760      }
761    }
762
763  set_index_to_next_mismatch() {
764    // Store the original currentIndex
765    const currentIndex = this.currentIndex;
766
767    // Find the first null label after the current index
768    const nextNullIndex = this.indexes_to_label
769      .map(i => this.data_array[i])
770      .findIndex((doc, index) => index > currentIndex && doc.label === null);
771
772    // If a null label is found, update currentIndex; otherwise, keep its original value.
773    if (nextNullIndex !== -1) {
774      this.currentIndex = nextNullIndex;
775    }
776  }
777
778apply_majority(threshold) {
779  this._sync_derived_arrays();
780  const selectedLabels = this.selected_label_array;
781  this.indexes_to_label.map(i =>
781 this.data_array[i]).forEach((doc, index) => {
782    // Only process documents with a null label
783    if (doc.label === null) {
784      const labels = selectedLabels[index];
785      if (!Array.isArray(labels) || labels.length < 2) return;
786      const labelCount = {};
787
788      // Calculate the percentages of accepted and rejected labels
789      const percentage_of_accepted = 100*countOccurrences(labels, 1) / labels.length;
790      const percentage_of_rejected = 100*countOccurrences(labels, 0) / labels.length;
791
792      if (percentage_of_accepted > threshold) {
793        doc.label = 1;
794      } else if (percentage_of_rejected > threshold) {
795        doc.label = 0;
796      }
797    }
798  });
799}
800
801  export_ris(filename, option = "relevant") {
802    // Filter entries based on option
803    let entries;
804    if (option === "relevant") {
805      entries = this.data_array.filter(entry => entry.label === 1);
806    } else if (option === "irrelevant") {
807      entries = this.data_array.filter(entry => entry.label === 0);
808    } else if (option === "all") {
809      entries = this.data_array;
810    } else {
811      return;
812    }
813
814    if (!entries.length) return;
815
816    const escapeRIS = (text) =>
817      text ? text.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() : '';
818
819    let risContent = entries.map(entry => {
820      let risEntry = '';
821
822      // 1️⃣ Type
823      risEntry += `TY  - JOUR\n`;
824
825      // 2️⃣ Title
826      if (entry.title) {
827        risEntry += `TI  - ${escapeRIS(entry.title)}\n`;
828      }
829
830      // 3️⃣ Authors
831      if (entry.authors) {
832        entry.authors
833          .split(';')
834          .map(a => a.trim())
835          .filter(Boolean)
836          .forEach(author => {
837            risEntry += `AU  - ${escapeRIS(author)}\n`;
838          });
839      }
840
841      // 4️⃣ Year
842      if (entry.year) {
843        risEntry += `PY  - ${escapeRIS(entry.year)}\n`;
844      }
845
846      // 5️⃣ Journal
847      if (entry.journal) {
848        risEntry += `T2  - ${escapeRIS(entry.journal)}\n`;
849      }
850
851      // 6️⃣ Abstract
852      if (entry.abstract) {
853        risEntry += `AB  - ${escapeRIS(entry.abstract)}\n`;
854      }
855
856      // 7️⃣ Keywords
857      if (entry.keywords) {
858        entry.keywords
859          .split(';')
860          .map(k => k.trim())
861          .filter(Boolean)
862          .forEach(keyword => {
863            risEntry += `KW  - ${escapeRIS(keyword)}\n`;
864          });
865      }
866
867      // 8️⃣ DOI or ID
868      if (entry.paper_id != null) {
869        const paperIdStr = String(entry.paper_id);
870        if (paperIdStr.includes('/')) {
871          risEntry += `DO  - ${escapeRIS(paperIdStr)}\n`;
872        } else {
873          risEntry += `ID  - ${escapeRIS(paperIdStr)}\n`;
874        }
875      }
876
877      // 9️⃣ Notes + label
878      let noteText = entry.notes ? escapeRIS(entry.notes) + ' ' : '';
879      noteText += `Label: ${entry.label === 1 ? 'included' : 'excluded'}`;
880      risEntry += `N1  - ${noteText}\n`;
881
882      // 🔟 End record
883      risEntry += `ER  - \n`;
884
885      return risEntry;
886    }).join('\n');
887
888    // Download RIS file
889    const blob = new Blob([risContent], {
890      type: "application/x-research-info-systems"
891    });
892    const link = document.createElement("a");
893    const downloadUrl = URL.createObjectURL(blob);
894    link.href = downloadUrl;
895    link.download = filename.endsWith('.ris') ? filename : filename + '.ris';
896    document.body.appendChild(link);
897    link.click();
898    document.body.removeChild(link);
899    setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
900  }
901
902
903
904cleanup(){
905
906  // Reset arrays holding label and paper ID data
907  this.label_array = [];
908  this.selected_label_array = [];
909  this.paper_id_array = [];
910  this.raters_identifiers = [];
911  this.data_array = [];
912  this.indexes_to_label = [];
913  this.currentIndex = 0;
914
915  // Clear overall metrics
916  this.agreement_percentage = [];
917  this.fleiss_kappa_stats = [];
918  this.fleiss_kappa_not_calculable_reason = null;
919
920  // Reset individual document agreement data
921  this.individual_agreement_array = [];
922  this.individual_agreement_colours = [];
923
924  // Clear raters' correlation metrics
925  this.corr_matrix_stats = [];
926  this.t_matrix_stats = [];
927  this.p_matrix_stats = [];
928  this.matrix_colours = [];
929  this.view_state = {};
930  this._save_meta = null;
931}
932
933
934
935}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.