PageSourceSearch

https://scoutstracker.ca/test/common/training.js?ver=%25VER%25

js scoutstracker.ca collected 2026-09-25 23:55:01 UTC 58,514 bytes, 1,565 lines download raw bytes

1// "USE Strict";
2/* global g_dbTables, g_tipPermanentTimestamps, g_currentBadgeID, g_reSearchHighliter, g_setTrainings_username, g_listQueuedTrainings */
3/* global console_log, console_error, console_trace  */
4/* global getLogin, getLoginID, getLoginName, getUsernameID, getEditionInfo, isHandbook, getLoginEmail, getBestLeader, isActiveMember, getDisplayName, hasPermYouth, getAccessedMembers */
5/* global openLightBox, lightBoxConfirm */
6/* global addPage, resetUpPoint, clearActive, setBackText, hasUpPoint, getCurrentPage, toggleSwitch, getSwitch */
7/* global showNewTip, acceptPermanentTip */
8/* global getSupportEmail, getVocab, clone, joinAnd, getNow, size, findKeysByProperty, sortTableKeysByIntField, ajaxUpdateProperty, setAccountPref, getGlobalPref, setGlobalPref, getEditionProp, setEditionProp */
9/* global addToJsonTable, deleteFromJsonTable, queueTransaction, updateQueuedCount, scheduleSync, storeTable_promise, updateSyncDisplay, getNumberOfSyncRecords, getReqs_byArea */
10/* global getBadge, deriveBadgeID, getRequirement, buildBadgeRequirements, asyncLoadImage, linkifyDescription, highliteSearch, clearHighliting, getCSSVariable, getRequirementCompetency, getRequirementCompetencyText */
11/* global applyFirstLast, hideEmptyListDividers, makePatrolDivider */
12/* global hasPermX, getMyPerms, getPermDescription */
13/* global round */
14/* global getCanvasTextWidth, mapCoordinatesToCanvas, isRootNode, getNodeGeneration, getNodeAncestors, getNodeDescendents, getNodePosition, setNodePosition, getCanvasFont, findUnderNode, drawGraph, initializeNodeLayout, optimizeNodeLayout, getNodeAreaIDs, getTreeAreaLabels, getTreeArea, calculateNodeGenerationsAndDescendents */
15
16/* global TRANSACTION_STATE, TRANSACTION_FLAG */
17/* global $, EDITION, POST_UPDATE_INTERVAL */
18
19let g_mapSortedTrainingSkills = null;
20var g_lastTrainingSyncTimestamp = 0;
21
22// this needs to be updated, as new areas are referenced
23const TRAINING_AREAS = {
24    help: { label: "Troubleshooting", color: "red" },
25    connecting: { label: "Connecting", color: "purple" },
26    collaborating: { label: "Collaborating", color: "green" },
27    roster: { label: "Roster", color: "deepskyblue" },
28    badges: { label: "Badge Progression", color: "brown" },
29    communicating: { label: "Communicating", color: "magenta" },
30    events: { label: "Events", color: "mediumblue" },
31    inventory: { label: "Inventory", color: "black" },
32    account: { label: "Account", color: "green" }
33};
34
35/* exported isTrainingBadge */
36function isTrainingBadge( badgeOrReqID )
37{
38    return deriveBadgeID( badgeOrReqID ) == "training0";
39}
40
41function addMyTraining( reqID, syncAfter = true )
42{
43    const loginID = getLoginID();
44    if ( loginID <= 0 )
45        return false;
46
47    const nStage_before = getTrainingStage_login( loginID );
48
49    updateMoreTrainingLink( reqID );
50
51    const usernameID = getUsernameID();
52    if ( isTrainingComplete_username( reqID, usernameID, loginID ) )	// have we already marked this as complete?
53        return false;							// nothing changed
54
55    const rc = addTraining( usernameID, reqID, true, syncAfter, getNow(), true );
56
57    const nStage_after = getTrainingStage_login( loginID );
58
59    if ( nStage_before != nStage_after )
60    {
61        const badgeID = "training" + nStage_after;
62
63	openLightBox( {
64	    text: `<div style="float:right;"><img alt="Your training stage" data-badgeid="${badgeID}" style="width:60px;"/></div>Congratulations, you've now reached <b>stage ${nStage_after}</b> in your ${getVocab( "PRODUCT_PLAIN" )} Training Skills!<p>To learn more, go to "Help" | "Training".`,
65	    canClose: true,
66	    align: "left",
67	    size: "w-xwide"
68	});
69
70	asyncLoadImage( "div.lightbox-message img[data-badgeid]", badgeID, 0 );
71    }
72
73    console_log( "completed " + reqID );
74
75    return rc;
76}
77
78function addTraining( usernameID, reqID, addToQueue, syncAfter, timestamp, bPersist )
79{
80    if ( addToQueue && (! g_setTrainings_username[usernameID] || g_setTrainings_username[usernameID][reqID] === undefined) )
81	queueTraining( usernameID, reqID, TRANSACTION_STATE.set.id, TRANSACTION_FLAG.completion.id, syncAfter, timestamp );		// defer the sync (if requested) if this an autoaward badge
82
83    const nChanged = addToJsonTable( "db-trainings", g_setTrainings_username, usernameID, reqID, { when: timestamp }, bPersist );
84
85    // is this a UI driven change (as opposed to a sync)
86    if ( nChanged && syncAfter && usernameID == getUsernameID() )
87        refreshTrainings();
88
89    return nChanged;
90}
91
92function clearMyTraining( reqID )
93{
94    const loginID = getLoginID();
95    if ( loginID <= 0 )
96        return false;
97
98    const nStage_before = getTrainingStage_login( loginID );
99
100    updateMoreTrainingLink( reqID );
101
102    const usernameID = getUsernameID();
103    if ( ! isTrainingComplete_username( reqID, usernameID, loginID ) )	// have we already unmarked this as complete?
104        return false;
105
106    const rc = clearTraining( usernameID, reqID, true, true, getNow(), true );
107
108    const nStage_after = getTrainingStage_login( loginID );
109
110    // need to decide if there's value in telling someone they dropped down
111    if ( false && nStage_before != nStage_after )
112    {
113        const badgeID = "training" + nStage_after;
114
115	openLightBox( {
116	    text: `<div style="float:right;"><img alt="Your training stage" data-badgeid="${badgeID}" style="width:60px;"/></div>Uh oh! you've now dropped down to <b>stage ${nStage_after}</b> in your ${getVocab( "PRODUCT_PLAIN" )} Training Skills!<p>To learn more, go to "Help" | "Training".`,
117	    canClose: true,
118	    align: "left",
119	    size: "w-xwide"
120	});
121
122	asyncLoadImage( "div.lightbox-message img[data-badgeid]", badgeID, 0 );
123    }
124
125    return rc;
126}
127
128function clearTraining( usernameID, reqID, addToQueue, syncAfter, timestamp, bPersist )
129{
130    if ( addToQueue && (g_setTrainings_username[usernameID] && g_setTrainings_username[usernameID][reqID] !== undefined) )
131	queueTransaction( usernameID, reqID, TRANSACTION_STATE.clear.id, TRANSACTION_FLAG.completion.id, syncAfter, timestamp );
132    else
133	syncAfter = false;					// nothing to sync, in case we needed to defer the sync
134
135    const nChanged = deleteFromJsonTable( "db-trainings", g_setTrainings_username, usernameID, reqID, bPersist );
136
137    if ( nChanged && syncAfter && usernameID == getUsernameID() )
138        refreshTrainings();
139
140    return nChanged;
141}
142
143function queueTraining( usernameID, requirementID, eState, eFlag, syncAfter, timestmp )
144{
145    if ( isHandbook() )
146	return;     // this doesn't apply to us
147
148    const listTrainings = getQueuedTrainings();
149
150    // delete any existing updates for this record
151    for ( let i = listTrainings.length - 1; i >= 0; i-- )
152    {
153	if ( listTrainings.usernameid == usernameID && listTrainings.requirementid == requirementID )
154	{
155	    listTrainings.splice( i, 1 );
156	    break;
157	}
158    }
159
160    listTrainings.push( {
161	usernameid: usernameID,
162	requirementid: requirementID,
163	state: eState,
164	flag: eFlag,
165	timestamp: timestmp
166    });
167
168    setQueuedTrainings( listTrainings ).then( function() {
169	updateQueuedCount();
170
171	if ( syncAfter )
172	    scheduleSync( "sync'd", POST_UPDATE_INTERVAL );
173    });
174}
175
176/* exported buildFoundTraining */
177function buildFoundTraining( selector, setTrainingIDs )
178{
179    const listReport = $( selector );
180    listReport.empty();
181
182    buildTrainingList( selector, getLoginID(), setTrainingIDs, true, g_reSearchHighliter );
183
184    const foundSomething = setTrainingIDs.size > 0;
185    listReport.toggle( foundSomething );
186    $( "#list-noresults-training" ).toggle( ! foundSomething );
187}
188
189function refreshTrainings()
190{
191    $( "#list-training" ).empty();					// force a rebuild the next time we visit the page
192    $( "#training-canvas" ).removeAttr( "data-built-allnodes" );	// force a rebuild the next time we visit the page
193    $( "#training-canvas" ).removeAttr( "data-built-subtree" );	// force a rebuild the next time we visit the page
194
195    if ( hasUpPoint( "training" ) )
196    {
197        gotoTrainingPage( false, false, undefined );			// rebuild the list, because the recommended skills may have changed
198	updateTrainingScore();
199    }
200
201    if ( getCurrentPage() == "training-skill" )
202        gotoTrainingSkill( null, false );				// rebuild the specific skill page, since this training or one of its ancestors/descendents may now be complete
203}
204
205function getQueuedTrainings()
206{
207    if ( ! g_listQueuedTrainings )
208        return [];
209
210    return g_listQueuedTrainings;
211}
212
213function setQueuedTrainings( listTrainings )
214{
215    g_listQueuedTrainings = listTrainings;
216    return storeTable_promise( "db-queued-trainings", listTrainings );
217}
218
219function showTip_currentTraining()
220{
221    const tipID = "training-current-popup";
222    if ( g_tipPermanentTimestamps[tipID] === undefined )
223    {
224	lightBoxConfirm( {
225	    text: `<b>
225Tip:</b> Even after you've navigated away from this page, the instructions for this training skill can be accessed by clicking on "Current Training" link in the sidebar menu.<p>Once you've followed the instructions and completed the training skill, the link will be changed to "More Training".`,
226	    labelTrue: "Got It!",
227	    labelFalse: "Remind Me Later",
228	    callbackTrue: `acceptPermanentTip( '${tipID}' );`,
229	    align: "left",
230	    trace: false
231	});
232    }
233}
234
235/* exported toggleShowRecommendedTraining */
236function toggleTrainingTreeView( bTreeView, bFromGUI )
237{
238    toggleSwitch( "#training-treeview", bTreeView );
239
240    $( "#training-onlyrecommended" ).closest( "li" ).toggle( ! bTreeView );
241    $( "#training-includeunattainable" ).closest( "li" ).toggle( ! getShowRecommendedTraining() );
242
243    $( "#training-skills-listview" ).toggle( ! bTreeView );
244    $( "#training-skills-treeview" ).toggle(   bTreeView );
245
246    if ( bFromGUI )
247	gotoTrainingPage( false, false, bTreeView );
248}
249
250function getTrainingTreeView()
251{
252    return getSwitch( "#training-treeview" );
253}
254
255/* exported toggleShowRecommendedTraining */
256function toggleShowRecommendedTraining( bOnlyRecommended )
257{
258    toggleSwitch( "#training-onlyrecommended", bOnlyRecommended );
259
260    $( "#training-includeunattainable" ).closest( "li" ).toggle( ! bOnlyRecommended );
261
262    updateTrainingHeader();
263    filterTraining();
264}
265
266function getShowRecommendedTraining()
267{
268    if ( getTrainingTreeView() )
269        return false;
270
271    return getSwitch( "#training-onlyrecommended" );
272}
273
274/* exported toggleShowUnattainableTraining */
275function toggleShowUnattainableTraining( bIncludeUnattainable )
276{
277    toggleSwitch( "#training-includeunattainable", bIncludeUnattainable );		// don't switch to list when you're embedded, or you've expressed a preferrence
278
279    filterTraining();
280}
281
282function getShowUnattainableTraining()
283{
284    if ( getShowRecommendedTraining() )
285        return false;
286
287    return getSwitch( "#training-includeunattainable" );
288}
289
290function updateTrainingHeader()
291{
292    const strSearch = $( "#training-filter-input" ).val();
293    $( "#list-training" ).prev().html( strSearch ? "Matching Skills" : getShowRecommendedTraining() ? "Recommended Skills" : "All Skills" );
294}
295
296function filterTraining()
297{
298    if ( getTrainingTreeView() )
299    {
300	drawTrainingRadialGraph();
301	return;
302    }
303
304    const bOnlyRecommended = getShowRecommendedTraining();
305    const bIncludeUnattainable = getShowUnattainableTraining();
306
307    const strSearch = $( "#training-filter-input" ).val();
308    const reSearchHighliter = strSearch ? new RegExp( `(${strSearch})`, "gi" ) : null;
309
310    clearHighliting( "#list-training" );
311    if ( reSearchHighliter )
312    {
313	$( "#list-training li.divider" ).each( function() {
314	    const span = $(this).find( "span.divider-label" );
315	    span.html( highliteSearch( span.text(), false, reSearchHighliter ) );
316	});
317    }
318
319    $( "#list-training li" ).removeClass( "hidden" ).show();
320
321    const setRequirementIDs = getTrainingSkills_all();
322    for ( const reqID of setRequirementIDs )
323    {
324	const req = getRequirement( reqID );
325
326	// first, cull any that shouldn't be show due to recommended/all toggle
327	if (   (bOnlyRecommended && ! strSearch)     && ! $( `#list-training li[data-reqid=${reqID}]` ).hasClass( "recommended" )	// not recommended
328	  || ! bIncludeUnattainable &&   $( `#list-training li[data-reqid=${reqID}]` ).hasClass( "noaccess" ) )		// not relevant to this login's permissions
329	{
330	    $( `#list-training li[data-reqid=${reqID}]` ).addClass( "hidden" );
331	    continue;
332	}
333
334	if ( strSearch )
335	{
336	    if ( req.description.toLowerCase().match( reSearchHighliter )
337	      || getTreeAreaLabels( TRAINING_AREAS, req ).toLowerCase().match( reSearchHighliter )
338	      || reqID.replace( /training0./, "" ).match( reSearchHighliter ) )
339	    {
340		let strDescription = req.description.replace( /<detail>.+?<\/detail>/s, "" );
341		strDescription = linkifyDescription( strDescription );
342		strDescription = highliteSearch( strDescription, false, reSearchHighliter );
343		$( `#list-training li[data-reqid=${reqID}] a span.competency` ).html( strDescription );			// highlite the text
344	    }
345	    else
346		$( `#list-training li[data-reqid=${reqID}]` ).addClass( "hidden" );					// didn't match the filter, so hide
347	}
348    }
349
350    $( "#list-training li.hidden" ).hide();
351
352    const nVisibleSkills = $( `#list-training li[data-reqid]:not(.hidden)` ).length;
353    $( "#list-training" ).toggle( nVisibleSkills > 0 );
354    $( "#training-noresults" ).toggle( nVisibleSkills == 0 );
355
356    if ( getCurrentPage() == "training" )
357    {
358	hideEmptyListDividers( "#list-training" );
359	applyFirstLast( "#list-training" );
360    }
361
362    const showChecks = $( "#list-training li[data-reqid]:not(.hidden).complete" ).length > 0;
363    $( "#list-training li[data-reqid] img.glyph" ).toggle( showChecks );
364
365    updateTrainingHeader();
366}
367
368// wrapper that takes a config, rather than a raw login ID
369function isTrainingSkillApplicable_config( req, config )
370{
371    return isTrainingSkillApplicable( req, config.loginid )
372}
373
374// req can be the object or just the string ID
375function isTrainingSkillApplicable( req, loginID )
376{
377    if ( typeof req == "string" )
378        req = getRequirement( req );
379
380    if ( loginID <= 0 || ! getLogin( loginID ) )
381        return true;
382
383    if ( req.autocompletion == "never" )		// this is a kludge to simulate unattainable skills
384        return false;
385
386    if ( req.extra.training.editions && ! req.extra.training.editions.includes( EDITION ) )
387        return false;
388
389    if ( req.extra.training.permission && ! hasPermX( req.extra.training.permission, loginID ) )
390        return false;
391
392    return true;
393}
394
395function getTrainingSkills_byArea( /* config */ )
396{
397    if ( ! g_mapSortedTrainingSkills )
398    {
399	calculateTrainingGenerationsAndDescendents();
400
401	// get the requirements, sorted by their display requirement number
402	const listRequirements = Array.from( getTrainingSkills_all() ).map( function( reqID ) { return getRequirement( reqID ); } );
403	const mapTreeAreas = TRAINING_AREAS;
404
405	g_mapSortedTrainingSkills = getReqs_byArea( mapTreeAreas, listRequirements );
406    }
407
408    return g_mapSortedTrainingSkills;
409}
410
411function getTrainingSkills_all()
412{
413    if ( ! g_dbTables || ! g_dbTables['Requirements'] )		// too early
414        return new Set();
415
416    if ( g_mapSortedTrainingSkills && size( g_mapSortedTrainingSkills ) > 0 )		// check if we've cached this information already
417        return new Set( Object.values( g_mapSortedTrainingSkills ).flat() );
418
419    return findKeysByProperty( g_dbTables['Requirements'], "badgeid", "training0" );
420}
421
422// Get the set of all training skills that are applicable to the login's permissions
423function getTrainingSkills_login( loginID )
424{
425    const setReqIDs = new Set();
426
427    for ( const reqID of getTrainingSkills_all() )
428	if ( isTrainingSkillApplicable( reqID, loginID ) )
429	    setReqIDs.add( reqID );
430
431    return setReqIDs;
432}
433
434// Get the set of all COMPLETED training skills
435function getCompletedTrainingSkills_all( loginID )
436{
437    const setReqIDs = new Set();
438    const usernameID = getUsernameID( loginID );
439
440    for ( const reqID of getTrainingSkills_all() )
441	if ( isTrainingComplete_username( reqID, usernameID, loginID ) )
442	    setReqIDs.add( reqID );
443
444    return setReqIDs;
445}
446
447// Get the set of all COMPLETED training skills that are applicable to the login's permissions
448function getCompletedTrainingSkills_login( loginID )			// eslint-disable-line no-unused-vars
449{
450    const setReqIDs = new Set();
451    const usernameID = getUsernameID( loginID );
452
453    for ( const reqID of getTrainingSkills_login( loginID ) )
454	if ( isTrainingComplete_username( reqID, usernameID, loginID ) )
455	    setReqIDs.add( reqID );
456
457    return setReqIDs;
458}
459
460function calculateTrainingStage( nCompleted, nTotal )
461{
462    return Math.trunc( 10 * nCompleted / (nTotal + 1) );
463}
464
465function getTrainingStage_login( loginID )
466{
467    const nTotal = getTrainingSkills_all().size;
468
469    const nCompleted = getCompletedTrainingSkills_all( loginID ).size;
470
471    return calculateTrainingStage( nCompleted, nTotal );
472}
473
474function getMaxTrainingStage_login( loginID )
475{
476    const nTotal = getTrainingSkills_all().size;
477
478    const nPossible = getTrainingSkills_login( loginID ).size;
479
480    return calculateTrainingStage( nPossible, nTotal );
481}
482
483function updateTrainingScore()
484{
485    const loginID = getLoginID();
486
487    const nMaxStage = getMaxTrainingStage_login( loginID );
488
489    let nStage = getTrainingStage_login( loginID );
490
491    $( ".skills-to-next-level" ).toggle( nStage < nMaxStage );
492    if ( nStage < nMaxStage )
493    {
494	const nTotal = getTrainingSkills_all().size;
495
496	//const nComplete = getCompletedTrainingSkills_login( loginID ).size;
497	const nComplete = getCompletedTrainingSkills_all( loginID ).size;
498
499	let nExtra = 0;
500	while ( true )
501	{
502	    const nNewStage = calculateTrainingStage( nComplete + ++nExtra, nTotal );
503	    if ( nNewStage > nStage || nNewStage >= nMaxStage )
504	        break;
505	}
506
507	$( ".skills-to-next-level span" ).html( nExtra == 1 ? "just one more skill" : `${nExtra < 10 ? "just " : ""}${nExtra} more skills` );
508    }
509
510    // highlight a Stage 0 score
511    if ( nStage == 0 )
512    {
513        nStage = `<span style="color:var(--color-warning-high);">${nStage}</span>`;
514	$( "#training img[data-badgeid]" ).attr( "src", "/images/training-postimage.png" );
515    }
516    else
517	asyncLoadImage( "#training img[data-badgeid]", "training" + nStage, 0 );
518
519    $( ".mytraining" ).html( nStage );
520
521}
522
523function buildTrainingList( selector, loginID, setFilterIDs, useDividers, reSearchHighliter )
524{
525    const list = $( selector );
526    const login = getLogin( loginID );
527
528    // sort the skills by area
529    const mapAreaSkillIDs = getTrainingSkills_byArea();
530
531    // if we set useDividers = false, then we want to prevent skills from showing up twice
532    const setProcessedSkillIDs = new Set();
533
534    // now build the list, using dividers
535    for ( const areaID in mapAreaSkillIDs )		// a skill can exist in multiple areas
536    {
537	const strAreaLabel = `<span style="font-weight:var(--font-weight-normal);">Area:</span> ` + highliteSearch( getTreeArea( TRAINING_AREAS, areaID ).label, false, reSearchHighliter );
538	let needsDivider = useDividers;
539
540	for ( const reqID of mapAreaSkillIDs[areaID] )
541	{
542	    const req = getRequirement( reqID );
543	    if ( setFilterIDs && ! setFilterIDs.has( reqID ) )			// do we only want specific items (e.g., when doing Search)
544	        continue;
545
546	    if ( setProcessedSkillIDs.has( reqID ) )
547	        continue;
548
549	    if ( needsDivider )
550	    {
551		list.append( makePatrolDivider( null, areaID, null, strAreaLabel, false ) );
552	        needsDivider = false;
553	    }
554
555	    list.append( buildTrainingListRow( login, req, areaID ) );
556
557	    if ( ! useDividers )
558	        setProcessedSkillIDs.add( reqID );
559	}
560    }
561}
562
563function buildTrainingListRow( login, req, areaID, jsOnClick )
564{
565    const reqID = req.requirementid;
566
567    if ( jsOnClick === undefined )
568        jsOnClick = `gotoTrainingSkill( '${reqID}', true );`;
569
570    const strSearch = $( "#training-filter-input" ).val();
571    const reSearchHighliter = strSearch ? new RegExp( `(${strSearch})`, "gi" ) : null;
572
573    const strDescription = getRequirementCompetency( req, reSearchHighliter );
574
575    let strClass = ! isTrainingSkillApplicable( req, login.loginid ) ? "noaccess" : "";
576    let imageID = "blank.gif";
577
578    if ( isTrainingComplete_username( reqID, getUsernameID( login ), login.loginid ) )
579    {
580	strClass += " complete";
581	imageID = "check.gif";
582    }
583
584    const strGlyph = `<img alt="" class="glyph" style="vertical-align:-2px;margin-right:8px;width:17px;" src="images/${imageID}">`;
585
586    let strSubtext = "";
587    if ( req.extra.training.editions && ! req.extra.training.editions.includes( EDITION ) )
588    {
589	const strEditions = joinAnd( req.extra.training.editions.map( function ( strEdition ) { const jsonEdition = getEditionInfo( strEdition ); return `<b>${jsonEdition.edition == jsonEdition.parent ? getVocab( "Committee" ) : jsonEdition.moniker}</b>`; } ) );
590	strSubtext = `Only applicable to the ${strEditions} ${req.extra.training.editions.length == 1 ? "edition" : "editions"} of ${getVocab( "PRODUCT_PLAIN" )}`;
591    }
592    else if ( req.extra.training.permission && ! hasPermX( req.extra.training.permission ) )
593	strSubtext += `Only applicable if you have the "<b>${getPermDescription( req.extra.training.permission )}</b>" login permission`;
594
595    if ( strSubtext )		// wrap it in an appropriate span
596        strSubtext = `<span class="subtext" style="color:var(--color-warning-medium);"><img alt="" class="glyph" style="vertical-align: -2px; margin-right: 8px; width: 17px; display: inline;" src="images/blank.gif"/>${strSubtext}</span>`;
597
598    return `<li class="forward ${strClass}" data-reqid="${reqID}" data-patrolid="${areaID}"><a href="javascript:void(0)" onclick="${jsOnClick}">${strGlyph}<span class="competency">${strDescription}</span>${strSubtext}</a></li>`;
599}
600
601function calculateTrainingGenerationsAndDescendents()
602{
603    const setRequirementIDs = getTrainingSkills_all();
604    calculateNodeGenerationsAndDescendents( setRequirementIDs )
605}
606
607function buildTrainingPage()
608{
609    const loginID = getLoginID();
610
611    if ( $( "#list-training li" ).length == 0 )
612	buildTrainingList( "#list-training", loginID, null, true, null );
613
614    $( "#list-training li[data-reqid]" ).removeClass( "recommended" );
615
616    const usernameID = getUsernameID();
617
618    const setRequirementIDs = getTrainingSkills_all();
619    for ( const reqID of setRequirementIDs )
620    {
621	const req = getRequirement( reqID );
622	if ( isTrainingComplete_username( reqID, usernameID, loginID ) )
623	    continue;				// only looking for incomplete requirements
624
625	for ( const ancestorReqID of getNodeAncestors( req ) )
626	{
627	    if ( isTrainingComplete_username( ancestorReqID, usernameID, loginID ) )
628	    {
629		$( `#list-training li[data-reqid=${reqID}]` ).addClass( "recommended" );
630		break;
631	    }
632	}
633    }
634}
635
636function gotoTrainingPage( bNavigate, bFromSidebar, bTreeView )
637{
638    setBackText( "#training", hasUpPoint( "help" ) ? "Help" : "Back" );
639
640    // we have to navigate first, because sizing the canvas requires that the page be visible
641    if ( bNavigate )
642	addPage( "#training" );
643
644    const loginID = getLoginID();
645
646    const nStage = getTrainingStage_login( loginID );
647    const badgeID = "training" + nStage;
648    $( "#training .white-page img[data-badgeid]" ).attr( "data-badgeid",  badgeID );
649    asyncLoadImage( "#training .white-page img[data-badgeid]", badgeID, 0 );
650
651    const bSeeMore = getGlobalPref( "prop-training-seemore", false );
652    $( "#training .white-page .training-seemore" ).toggle(   bSeeMore );
653    $( "#training .white-page .training-seeless" ).toggle( ! bSeeMore );
654    $( "#training .white-page img[data-badgeid]" ).css( "height", bSeeMore ? "80px" : "50px" );
655
656    updateTrainingScore();
657
658    if ( bTreeView === undefined )			// not specified?  re-use whatever settings, otherwise record the setting
659        bTreeView = $( "#training" ).attr( "data-view" ) == "tree";
660    else
661    {
662        $( "#training" ).attr( "data-view", bTreeView ? "tree" : "list" );
663	toggleTrainingTreeView( bTreeView, false );
664    }
665
666    if ( bTreeView )
667    {
668	drawTrainingRadialGraph();
669
670	acceptPermanentTip( "training-skills-tree" );
671	if ( bFromSidebar )
672	    acceptPermanentTip( "training-more-skills" );
673    }
674
675    else
676    {
677	updateTrainingHeader();
678
679	buildTrainingPage();
680	filterTraining();
681
682	hideEmptyListDividers( "#list-training" ); 		// we may have just hidden all of the sections in the divider, but this function only works if the page is visible
683	applyFirstLast( "#list-training" );
684
685	acceptPermanentTip( "training-skills-list" );
686	if ( bFromSidebar )
687	    acceptPermanentTip( "training-more-skills" );
688    }
689}
690
691/**
692 * Takes a LIST of requirements (typically, descendentids or ancestorids) and returns a (possibly empty) SET of requirements, that is either all of input reqs or those that are attainable
693 */
694function filterAttainableSkills( loginID, listReqIDs, bIncludeUnattainable )
695{
696    if ( ! listReqIDs || listReqIDs.length == 0 )		// nothing passed in?  return an empty set
697        return new Set();
698
699    if ( bIncludeUnattainable )					// return all the reqs
700        return new Set( listReqIDs );
701
702    const listFilteredReqIDs = [];
703    if ( listReqIDs )
704    {
705	for ( const reqID of listReqIDs )
706	    if ( isTrainingSkillApplicable( reqID, loginID ) )
707		listFilteredReqIDs.push( reqID );
708    }
709
710    return new Set( listFilteredReqIDs );
711}
712
713function gotoTrainingSkill( reqID, bNavigate )
714{
715    if ( ! reqID )
716    {
717        reqID = $( "#training-skill [data-details-role=requirements] tr[data-reqid]" ).attr( "data-reqid" );
718	console_log( "retrieved skill " + reqID );
719    }
720
721    reqID = reqID.replace( /<.?mark>/g, "" );					// just in case it's highlited
722
723    calculateTrainingGenerationsAndDescendents();				// make sure we have the generations
724
725    const loginID = getLoginID();
726    const login = getLogin( loginID );
727    const usernameID = getUsernameID( login );
728
729    const req = getRequirement( reqID );
730    g_currentBadgeID = req.badgeid;
731
732    const bIncludeUnattainable = getShowUnattainableTraining();
733
734    // build up the generations of ancestors, back to the first completed one
735    $( "#div-trainingskill-prev" ).empty();
736
737    const setKeeperIDs = new Set();
738    let listCandidateIDs = [ reqID ];
739
740    let bKeepLooking = true;
741    while ( bKeepLooking )
742    {
743	if ( listCandidateIDs.length == 0 )
744	    break;
745
746	const listCandidateIDs_working = clone( listCandidateIDs );
747	for ( const ancestorReqID of listCandidateIDs_working )
748	{
749	    if ( setKeeperIDs.has( ancestorReqID ) )				// check for diamond paths
750	    {
751                const iPos = listCandidateIDs.indexOf( ancestorReqID );
752		listCandidateIDs.splice( iPos, 1 );
753		continue;
754	    }
755
756	    if ( ! bIncludeUnattainable && ! isTrainingSkillApplicable( ancestorReqID, loginID ) )
757	    {
758                const iPos = listCandidateIDs.indexOf( ancestorReqID );
759		listCandidateIDs.splice( iPos, 1 );
760	        continue;
761	    }
762
763	    setKeeperIDs.add( ancestorReqID );					// we always want the candidate reqs
764
765	    const ancestorReq = getRequirement( ancestorReqID );
766
767	    if ( getNodeGeneration( ancestorReq ) == 0 || ancestorReqID != reqID && isTrainingComplete_username( ancestorReqID, usernameID, loginID ) )	// did we finally find an ancestor that is incomplete (or run out of ancestors)?
768		bKeepLooking = false;
769
770	    if ( ! isRootNode( ancestorReq ) )
771		listCandidateIDs = listCandidateIDs.concat( getNodeAncestors( ancestorReq ) );
772	}
773    }
774
775    setKeeperIDs.delete( reqID );						// this was added to prevent looping
776
777    if ( setKeeperIDs.size > 0 )
778    {
779	// group the keepers by ascending generation
780	const mapListReqsByGeneration = {};
781	for ( const ancestorReqID of setKeeperIDs )
782	{
783	    const nGeneration = getNodeGeneration( ancestorReqID );
784
785	    let listReqIDs = mapListReqsByGeneration[nGeneration];
786	    if ( ! listReqIDs )
787	    {
788		listReqIDs = [];
789		mapListReqsByGeneration[nGeneration] = listReqIDs;
790	    }
791	    listReqIDs.push( ancestorReqID );
792	}
793
794	const nMaxGeneration = Math.max( ...Object.keys( mapListReqsByGeneration ).map(Number) );
795
796	for ( let iGeneration = 0; iGeneration <= nMaxGeneration; iGeneration++ )
797	{
798	    const listReqIDs = mapListReqsByGeneration[iGeneration];
799	    if ( listReqIDs )
800	    {
801		const ul = $( `<ul class="rounded edit"></ul>` );
802		for ( const ancestorReqID of listReqIDs )
803		    ul.append( buildTrainingListRow( login, getRequirement( ancestorReqID ), null ) );
804
805		$( "#div-trainingskill-prev" ).append( ul );
806
807		if ( iGeneration < nMaxGeneration )
808		    $( "#div-trainingskill-prev" ).append( `<div style="width:100%;text-align:center;margin-top:-15px;margin-bottom:-15px;font-size:150%;">&#8659;</div>` );
809	    }
810	}
811    }
812
813    const bShowPrev = $( "#div-trainingskill-prev" ).children().length > 0;
814    $( "#div-trainingskill-prev" ).toggle( bShowPrev );
815    $( "#div-trainingskill-prev" ).prev().toggle( bShowPrev );
816
817    const strRequirements = buildBadgeRequirements( g_currentBadgeID, getBadge( g_currentBadgeID ), [ req ], usernameID, true, null );
818    const strSearch = $( "#training-filter-input" ).val();
819    const reSearchHighliter = strSearch ? new RegExp( `(${strSearch})`, "gi" ) : null;
820    $( "#trainingskill-thisskill [data-details-role=requirements]" ).html( highliteSearch( strRequirements, false, reSearchHighliter ) );
821    $( "#trainingskill-thisskill [data-details-role=requirements] > table" ).css( "color", "black" );
822
823    const strAreas = highliteSearch( getTreeAreaLabels( TRAINING_AREAS, req ), false, reSearchHighliter );
824    $( "#trainingskill-thisskill li.divider span.training-theme" ).prev().html( getNodeAreaIDs( req ).length == 1 ? "Area:" : "Areas:" );
825    $( "#trainingskill-thisskill li.divider span.training-theme" ).html( strAreas );
826
827    // tweak the divider
828    const strTag = req.requirementid.replace( /[^.]+[.](.+)/, "$1" );			// turn "training.abcde123" into "abcde123"
829    $( "#trainingskill-thisskill li.divider div" ).html( `<span style="font-weight: var(--font-weight-normal);">ID:</span> ${strTag}` );
830
831    // add a "See also" line
832    $( "#trainingskill-seealso" ).remove();			// get rid of any previous version
833    if ( req.extra.training.seealsoids )
834    {
835	const listLinks = [];
836	for ( const alsoReqID of req.extra.training.seealsoids )
837	{
838	    const reqAlso = getRequirement( alsoReqID );
839	    if ( reqAlso )
840	    {
841		const jsOnClick_also = `gotoTrainingSkill( '${alsoReqID}', false );`;
842		const strAlsoText = getRequirementCompetency( reqAlso );
843		listLinks.push( `<a href="javascript:void(0)" onclick="${jsOnClick_also}" class="seealso">${strAlsoText}</a>` );
844	    }
845	}
846
847	if ( listLinks.length > 0 )
848	    $( "#trainingskill-thisskill" ).append( `<li id="trainingskill-seealso"><span class="training-instruction-label" style="display:inline;">Related skills:</span>${listLinks.join( ",&nbsp; " )}</li>` );
849    }
850
851    // add a custom footer asking for help improving this req ID
852    $( `#training-skill div.hint a` ).attr( "href", `mailto:${getSupportEmail()}?subject=${getVocab( "PRODUCT_PLAIN" )}%20Training:%20Topic%20${strTag}` );
853
854    // need to clean up some empty paragraphs, or the height sizing of some rows can be wonky.
855    // The issue is that I can generate p elements with nested block items (e.g., "<p>blah<div>bleh</div></p>"),
856    // which is not valid HTML.  The browser responds by terminating the p elements where it this is best.
857    // E.g., "<p>blah</p><div>bleh</div><p></p>", so you can get blank lines
858    $( `#trainingskill-thisskill [data-details-role=requirements] p:empty` ).remove();
859
860    // hide the requirement number, make sure the details can be seen
861    $( `#trainingskill-thisskill [data-details-role=requirements] td.reqnum` ).hide();
862    $( `#trainingskill-thisskill [data-details-role=requirements] aside` ).show();
863    $( `#trainingskill-thisskill [data-details-role=requirements] td + td` ).css( "cursor", "default" );		// remove the pointer from all but the checkbox
864
865    // build up the list of follow-on training
866    const setFilteredDescendentReqIDs = filterAttainableSkills( loginID, getNodeDescendents( req ), bIncludeUnattainable );
867
868    const bShowNext = !! setFilteredDescendentReqIDs && setFilteredDescendentReqIDs.size > 0;
869
870    $( "#list-trainingskill-next" ).toggle( bShowNext );
871    $( "#list-trainingskill-next" ).prev().toggle( bShowNext );
872    $( "#list-trainingskill-next" ).empty();
873
874    if ( bShowNext )
875    {
876	// are all the descendents in the same area?
877	const setAreas = new Set();
878	for ( const descendentReqID of setFilteredDescendentReqIDs )
879	    setAreas.add( JSON.stringify( getNodeAreaIDs( descendentReqID ) ) );	// it's okay if there are multiple areas, e.g., "help,connecting"
880	buildTrainingList( "#list-trainingskill-next", login.loginid, setFilteredDescendentReqIDs, setAreas.size > 1, reSearchHighliter );
881    }
882
883    if ( bNavigate )
884	addPage( "#training-skill" );
885
886    if ( getRequirement( reqID ).autocompletion != "true" )
887	showCurrentTrainingLink( reqID );
888}
889
890/* exported toggleTrainingSkill */
891function toggleTrainingSkill( reqID )
892{
893    reqID = reqID.replace( /<.?mark>/g, "" );		// just in case it's highlited
894
895    const req = getRequirement( reqID );
896
897    if ( ! req )
898    {
899        console_trace( "couldn't find reqID " + reqID );
900	return;
901    }
902
903    const wasComplete = isTrainingComplete_username( reqID, getUsernameID(), getLoginID() );
904
905    let strMessage = "";
906    if ( wasComplete && req.autocompletion == "true" )
907        strMessage = "You cannot unmark this training as complete.";
908
909    else if ( ! wasComplete && req.autocompletion )
910        strMessage = "The instructions to complete this training are provided.  Once followed, this training will automatically be marked as complete.";
911
912    if ( strMessage )
913    {
914	openLightBox( { text: strMessage, canClose: true, size: "w-xwide" } );
915	return;
916    }
917
918    if ( wasComplete )
919        clearMyTraining( reqID );
920    else
921        addMyTraining( reqID );
922}
923
924function updateMoreTrainingLink( reqID )
925{
926    const li = $( `div.sidebar li a[data-pageid=current-training][reqid=${reqID}]` ).closest( "li" );		// do we have a current training link?
927
928    if ( li.length > 0 )
929    {
930	resetCurrentTraining();
931
932	const tipID = "training-more-skills";
933
934	$( "div.sidebar li a[data-pageid=help]" ).closest( "li" ).after( `<li><a data-pageid="more-training" href="javascript:void(0)" onclick="resetCurrentTraining(); gotoTrainingPage( true, true, undefined );">More Training<span class="newtip tip-${tipID}" style="display:none;"></span></a></li>` );
935
936	if ( g_tipPermanentTimestamps[tipID] === undefined )
937	    showNewTip( tipID );
938    }
939}
940
941function resetCurrentTraining()
942{
943    $( "div.sidebar li a[data-pageid=current-training]" ).closest( "li" ).remove();	// remove the old link to a specific training
944    $( "div.sidebar li a[data-pageid=more-training]" ).closest( "li" ).remove();	// remove the old link to the more skills page
945}
946
947function showCurrentTrainingLink( reqID )
948{
949    resetCurrentTraining();
950
951    if ( reqID )									// do we need to add a new link
952    {
953	const tipID = "training-current";
954
955	$( "div.sidebar li a[data-pageid=help]" ).closest( "li" ).after( `
955<li><a data-pageid="current-training" href="javascript:void(0)" reqid="${reqID}" onclick="showCurrentTraining();">Current Training<span class="newtip tip-${tipID}" style="display:block;"></span></a></li>` );
956
957	if ( g_tipPermanentTimestamps[tipID] === undefined )
958	    showNewTip( tipID );
959
960	showTip_currentTraining();
961    }
962}
963
964/* exported showCurrentTraining */
965function showCurrentTraining()
966{
967    clearActive();
968
969    const div = $( "<div>" + $( "#trainingskill-thisskill td+td+td" ).html() + "</div>" );
970    const htmlAside = div.find( "aside" ).html();
971
972    let htmlInstructions = div.find( "p.training-instruction" ).remove().html();
973    if ( htmlInstructions )
974	htmlInstructions = `<p class="training-instruction">${htmlInstructions}</p>`;
975    else
976        htmlInstructions = "";
977
978    div.find( "aside" ).remove();
979
980    const reqID = $( "div.sidebar li a[data-pageid=current-training" ).attr( "reqid" );
981
982    const login = getLogin( getLoginID() );
983    const req = getRequirement( reqID );
984    const bIncludeUnattainable = getShowUnattainableTraining();
985
986    const listDescendents = [];
987    if ( getNodeDescendents( req ) )
988    {
989	for ( const descendentReqID of getNodeDescendents( req ) )
990	{
991	    if ( ! bIncludeUnattainable && ! isTrainingSkillApplicable( descendentReqID, getLoginID() ) )
992	        continue;
993
994	    const jsOnClick = `gotoTrainingSkill( '${descendentReqID}', false ); closeLightBox( true ); showCurrentTraining();`;
995	    listDescendents.push( buildTrainingListRow( login, getRequirement( descendentReqID ), null, jsOnClick ) );
996	}
997    }
998
999    const htmlDescendents = listDescendents.length > 0 ? `<h1 class="listheader" style="margin: 15px 0 5px; font-size: 1em;">Follow-on Training Skills to Consider</h1><ul class="rounded edit" style="margin: 0;">${listDescendents.join( "" )}</ul>` : "";
1000
1001    openLightBox( {
1002        text: "<b>" + div.find( "p" ).text() + "</b><p>" + htmlAside + htmlInstructions + htmlDescendents,
1003	canClose: true,
1004	align: "left",
1005	size: "w-xwide"
1006    } );
1007
1008    $( "div.lightbox-message ul li.forward" ).css( "background-image", "none" );
1009
1010    acceptPermanentTip( "training-current" );
1011}
1012
1013function isTrainingComplete_config( reqID, config )
1014{
1015    return isTrainingComplete_username( reqID, getUsernameID( config.loginid ), config.loginid );
1016}
1017
1018function isTrainingComplete_username( reqID, usernameID, loginID )
1019{
1020    const req = getRequirement( reqID );
1021    if ( ! req )
1022        return false;
1023
1024    if ( req.autocompletion == "training:true" )
1025        return true;
1026
1027    // Deal with some special circumstances, and return true if not relevant
1028
1029    // This training requires *changing* the current member, which is impossible if you're a parent login with only one kid
1030    if ( reqID == "training0.wjoiq937" && ! hasPermYouth( loginID ) && getAccessedMembers( loginID ).length == 1 )
1031        return true;
1032
1033    return g_setTrainings_username[usernameID] && g_setTrainings_username[usernameID][reqID] !== undefined;
1034}
1035
1036/* exported setLastTrainingSyncTimestamp */
1037function setLastTrainingSyncTimestamp( timestamp )
1038{
1039    if ( isNaN( timestamp ) )
1040	g_lastTrainingSyncTimestamp = parseInt( timestamp );
1041    else
1042	g_lastTrainingSyncTimestamp = timestamp;
1043
1044    // now we want to persist this value for future uses of the application
1045    setAccountPref( "prop-lasttrainingsync", timestamp );
1046
1047    updateSyncDisplay();
1048}
1049
1050/* exported getLastTrainingSyncTimestamp */
1051function getLastTrainingSyncTimestamp()
1052{
1053    if ( getNumberOfSyncRecords( g_setTrainings_username ) == 0 )		// check that our indexedDB didn't get deleted
1054	return 0;
1055
1056    return g_lastTrainingSyncTimestamp;
1057}
1058
1059/* exported toggleMoreTrainingText */
1060function toggleMoreTrainingText( bShowMore )
1061{
1062    setGlobalPref( "prop-training-seemore", bShowMore );
1063    gotoTrainingPage( false, false, undefined );
1064}
1065
1066/* exported onTrainingMouseMove */
1067function onTrainingMouseMove( evt )
1068{
1069    const mousePosition = mapCoordinatesToCanvas( $( "#training-canvas" ), evt.pageX, evt.pageY );
1070
1071    // DAC: Leave the tooltip where it's first placed, rather than dragging it around with the mouse until you exit the node
1072    //$( "#training-canvas-tooltip" ).remove();
1073
1074    const isAllNodes = getShowUnattainableTraining();
1075    const req = findUnderNode( mousePosition, isAllNodes, getTrainingSkills_all() );
1076    if ( req )
1077    {
1078	const reqID = req.requirementid;
1079
1080	const oldReqID = $( "#training-canvas-tooltip" ).attr( "data-reqid" );
1081	if ( oldReqID == reqID )
1082	    return;					// we're already displaying this tooltip
1083
1084	// console_log( "found req " + reqID + " at " + mousePosition.x + "," + mousePosition.y );
1085	$( "#training-canvas-tooltip" ).remove();	// dismiss the old canvas
1086
1087	const isInvalid = ! isTrainingSkillApplicable( req, getLoginID() );
1088
1089	// now build a new canvas
1090
1091	// first find the max width of the (potentially) three lines of text
1092	const strText = getRequirementCompetencyText( req ) + ( getLoginEmail() == "[email protected]" ? ( ": " + reqID.replace( /training0./, "" ) ) : "" );
1093	const strAreaLabel = getNodeAreaIDs( req ).length == 1 ? "Area: " : "Areas: ";
1094	const strAreas = getTreeAreaLabels( TRAINING_AREAS, req );
1095	let strInvalidReason = "";
1096        if ( isInvalid )
1097	{
1098	    if ( req.extra.training.editions && ! req.extra.training.editions.includes( EDITION ) )
1099	    {
1100		const strEditions = joinAnd( req.extra.training.editions.map( function ( strEdition ) { const jsonEdition = getEditionInfo( strEdition ); return `${jsonEdition.edition == jsonEdition.parent ? getVocab( "Committee" ) : jsonEdition.moniker}`; } ) );
1101	        strInvalidReason = `Applicable ${req.extra.training.editions.length == 1 ? "edition" : "editions"}: ${strEditions}`;
1102	    }
1103	    else if ( req.extra.training.permission && ! hasPermX( req.extra.training.permission ) )
1104	        strInvalidReason = `Requires the "${getPermDescription( req.extra.training.permission )}" permission`;
1105	}
1106
1107	const FONT_SCALE = 0.9;
1108	const nTextWidth = getCanvasTextWidth( strText, FONT_SCALE );
1109	const nAreaLabelWidth = getCanvasTextWidth( strAreaLabel, FONT_SCALE );
1110	const nAreasWidth = getCanvasTextWidth( strAreas, FONT_SCALE );
1111	const nInvalidWidth = strInvalidReason ? getCanvasTextWidth( strInvalidReason, FONT_SCALE ) : 0;
1112
1113	const WIDTH = Math.max( nTextWidth, nAreaLabelWidth + nAreasWidth, nInvalidWidth ) + 20;
1114	const HEIGHT = 50 + (strInvalidReason ? 25 : 0);
1115
1116        const fRatio = window.devicePixelRatio;
1117	const canvas = document.createElement( "canvas" );
1118	canvas.setAttribute( "id", "training-canvas-tooltip" );
1119	canvas.setAttribute( "data-reqid", reqID );			// record the reqID for the onClick handler
1120	canvas.width = WIDTH * fRatio;
1121	canvas.height = HEIGHT * fRatio;
1122	canvas.style.width = WIDTH + "px";
1123	canvas.style.height = HEIGHT + "px";
1124
1125	const ctx = canvas.getContext( "2d" );
1126	ctx.scale( fRatio, fRatio );
1127
1128	ctx.clearRect( 0, 0, canvas.width, canvas.height );
1129
1130	ctx.fillStyle = getCSSVariable( "--color-background-divider" );
1131	ctx.fillRect( 0, 0, WIDTH, 22 );
1132
1133	const nAreasLeft = ( WIDTH - ( nAreaLabelWidth + nAreasWidth ) ) / 2;
1134	ctx.font = getCanvasFont( FONT_SCALE, 300 );			// TODO: hard-coded font-width
1135	ctx.fillStyle = "white";
1136	ctx.fillText( strAreaLabel, nAreasLeft, 16 );
1137
1138	ctx.font = getCanvasFont( FONT_SCALE, 400 );			// TODO: hard-coded font-width
1139	ctx.fillStyle = "white";
1140	ctx.fillText( strAreas, nAreasLeft + nAreaLabelWidth, 16 );
1141
1142	ctx.font = getCanvasFont( FONT_SCALE );
1143	ctx.fillStyle = "black";
1144	ctx.fillText( strText, 10, 40 );
1145
1146        if ( strInvalidReason )
1147	{
1148	    ctx.fillStyle = "#800";
1149	    ctx.fillText( strInvalidReason, 10, 64 );
1150	}
1151
1152	// make sure that the entire tooltip fits on the window
1153	let nOvershoot = (evt.pageX + WIDTH) - window.innerWidth + 30;
1154	if ( nOvershoot < 0 )
1155	    nOvershoot = 0;
1156
1157	// position and add it to the dom
1158	canvas.style.left = ( evt.pageX - nOvershoot ) + "px";
1159	canvas.style.top = evt.pageY + "px";
1160	$( "#training-canvas" ).after( canvas );
1161	$( "#training-canvas-tooltip" ).click( function() {
1162	    $( "#training-canvas-tooltip" ).remove();
1163
1164	    gotoTrainingSkill( reqID, true );
1165	} );
1166    }
1167
1168    else
1169	$( "#training-canvas-tooltip" ).remove();
1170}
1171
1172/* exported onTrainingMouseUp */
1173function onTrainingMouseUp( evt )
1174{
1175    const canvas = $( "#training-canvas" );
1176    const mousePosition = mapCoordinatesToCanvas( canvas, evt.pageX, evt.pageY );
1177
1178    const isAllNodes = getShowUnattainableTraining();
1179    const req = findUnderNode( mousePosition, isAllNodes, getTrainingSkills_all() );
1180    if ( req )
1181    {
1182	gotoTrainingSkill( req.requirementid, true );
1183	drawTrainingRadialGraph();
1184    }
1185}
1186
1187function drawTrainingRadialGraph()
1188{
1189    $( "#training-canvas-tooltip" ).remove();
1190
1191    const ctx = document.getElementById( "training-canvas" ).getContext( "2d" );
1192    const isAllNodes = getShowUnattainableTraining();
1193    const loginID = getLoginID();
1194
1195    const strSearch = $( "#training-filter-input" ).val();
1196    const reSearchHighliter = strSearch ? new RegExp( `(${strSearch})`, "gi" ) : null;
1197    const currentReqID = $( `div.sidebar li a[data-pageid=current-training][reqid]` ).attr( "reqid" );
1198
1199    const setRequirementIDs_all = getTrainingSkills_all();
1200
1201    calculateTrainingGenerationsAndDescendents();						// make sure we have the generations
1202
1203    const config = {
1204	// properties that are specific to the paradigm (e.g., Training vs OAS), regardless of whose (sub)tree we're showing
1205	paradigm: {
1206	    areas: TRAINING_AREAS,
1207	    funcNodesByArea: getTrainingSkills_byArea,
1208	    funcApplicable: isTrainingSkillApplicable_config,
1209	    funcComplete: isTrainingComplete_config,
1210	    funcUpdateCache: updateCachedTrainingLayout_config
1211	},
1212
1213	// properties that are specific to the specific person
1214	loginid: loginID,
1215	isAllNodes: isAllNodes,
1216	setRequirementIDs: new Set()
1217    }
1218
1219    if ( ctx.canvas.getAttribute( `data-built-${isAllNodes ? "allnodes" : "subtree"}` ) )	// just redraw if we've already positioned all the nodes
1220    {
1221	config.setRequirementIDs = isAllNodes ? setRequirementIDs_all : getTrainingSkills_login( loginID );
1222
1223	// if a refresh happened, then we might not have any position information.  So we check all the nodes to make sure we're still good to draw
1224	let isOkay = true;
1225	for ( const reqID of config.setRequirementIDs )
1226	{
1227	    if ( ! getNodePosition( reqID, true ) )
1228	    {
1229		ctx.canvas.removeAttribute( `data-built-${isAllNodes ? "allnodes" : "subtree"}` );
1230
1231	        isOkay = false;
1232		break;
1233	    }
1234	}
1235
1236	// if all the position information is still there, we're good to redraw
1237	if ( isOkay )
1238	{
1239	    drawGraph( ctx, config, currentReqID, reSearchHighliter );
1240	    return;
1241	}
1242    }
1243
1244    // But wait... is there a cached "allnodes" layout we can use, instead of re-doing all the complicated optimization?
1245    let mapCachedPositions_all = getGlobalPref( "global-pref-traininglayout-global", "" );
1246    if ( mapCachedPositions_all )
1247    {
1248        mapCachedPositions_all = JSON.parse( mapCachedPositions_all );					// parse the string as an object
1249
1250	// sanity test to make sure that the cached layout contains all the nodes we need to position
1251	let isOkay = true;
1252	for ( const reqID of setRequirementIDs_all )
1253	    if ( ! mapCachedPositions_all[reqID] )
1254	        isOkay = false;
1255
1256	if ( isOkay )
1257	{
1258	    // iterate over all the nodes and update their positions from the cached data
1259	    for ( const reqID of setRequirementIDs_all )
1260		setNodePosition( reqID, mapCachedPositions_all[reqID], true );
1261
1262	    console_log( "reconstructed global layout from cache" );
1263	    ctx.canvas.setAttribute( "data-built-allnodes", true );		// record that we never need to do this optimization again
1264	}
1265    }
1266
1267    // we always build the primary first (if necessary), and then optionally tweak for the alternate layout
1268    if ( ! ctx.canvas.getAttribute( `data-built-allnodes` ) )
1269    {
1270	config.setRequirementIDs = setRequirementIDs_all;
1271	config.isAllNodes = true;
1272
1273	initializeNodeLayout( config );
1274
1275	optimizeNodeLayout( ctx, config, 0.0002, 8000, reSearchHighliter, true, function() {
1276	    config.setRequirementIDs = getTrainingSkills_login( loginID );
1277	    config.isAllNodes = false;
1278
1279	    optimizeNodeLayout_login( ctx, config, currentReqID, reSearchHighliter );
1280	} );
1281    }
1282    else
1283    {
1284	config.setRequirementIDs = getTrainingSkills_login( loginID );
1285	config.isAllNodes = false;
1286
1287        optimizeNodeLayout_login( ctx, config, currentReqID, reSearchHighliter );
1288    }
1289}
1290
1291function optimizeNodeLayout_login( ctx, config, currentReqID, reSearchHighliter )
1292{
1293    const loginID = config.loginid;
1294    const isAllNodes = config.isAllNodes;
1295
1296    const setRequirementIDs = getTrainingSkills_login( loginID );
1297
1298    if ( ! isAllNodes )
1299    {
1300	// At this point, we're guaranteed to have the allnodes layout done.   But again, we may actually be able to reuse some cached values for my subtree (edition+perms)
1301	const strMyPerms = getMyPerms().join( "," );
1302
1303	let mapCachedPositions_perms = getEditionProp( "edition-pref-traininglayout-perms" );
1304	if ( mapCachedPositions_perms )
1305	{
1306	    mapCachedPositions_perms = JSON.parse( mapCachedPositions_perms );			// parse the string as an object
1307
1308	    if ( mapCachedPositions_perms[strMyPerms] )						// my specific permissions are just one property in the edition+perms map
1309	    {
1310		mapCachedPositions_perms = mapCachedPositions_perms[strMyPerms];		// we're only interested in this subtree
1311
1312		// sanity test to make sure that the cached layout contains all the nodes we need to position
1313		let isOkay = true;
1314		for ( const reqID of setRequirementIDs )
1315		    if ( ! mapCachedPositions_perms[reqID] )
1316			isOkay = false;
1317
1318		if ( isOkay )
1319		{
1320		    // iterate over all the nodes and update their positions from the cached data
1321		    for ( const reqID of getTrainingSkills_all() )
1322			//if ( mapCachedPositions_perms[reqID] )
1323			    setNodePosition( reqID, mapCachedPositions_perms[reqID], false );
1324			//else
1325			    //setNodePosition( reqID, mapCachedPositions_all[reqID], false );
1326
1327		    console_log( "reconstructed perms layout from cache" );
1328		    ctx.canvas.setAttribute( "data-built-subtree", true );		// record that we never need to do this optimization again
1329		}
1330	    }
1331	}
1332
1333	// so, do I really need to build the edition+perms subtree?
1334	if ( ! ctx.canvas.getAttribute( `data-built-subtree` ) )
1335	{
1336	    // we can save ourselves a lot of iteration by reusing the primary layout, which is guaranteed to have been calculated
1337	    for ( const reqID of setRequirementIDs )
1338	    {
1339		const req = getRequirement( reqID );
1340		setNodePosition( req, getNodePosition( req, ! isAllNodes ), isAllNodes );	// copy the primary position to the alt
1341	    }
1342
1343	    config.isAllNodes = false;
1344	    optimizeNodeLayout( ctx, config, 0.0001, 8000, reSearchHighliter, true, function() {
1345		drawGraph( ctx, config, currentReqID, reSearchHighliter ); // draw the final results
1346	    } );
1347	}
1348	else
1349	    drawGraph( ctx, config, currentReqID, reSearchHighliter ); // draw the final results
1350    }
1351    else
1352	drawGraph( ctx, config, currentReqID, reSearchHighliter ); // draw the final results
1353}
1354
1355function updateCachedTrainingLayout_config( config )
1356{
1357    const strMyPerms = config.isAllNodes ? null : getMyPerms().join( "," );
1358
1359    // extract all of the positions for the appropriate nodes.  Trim down to 3 decimal places.
1360    let mapCachedPositions = {};
1361    for ( const reqID of config.setRequirementIDs )
1362    {
1363	const pos = getNodePosition( reqID, config.isAllNodes );
1364	mapCachedPositions[reqID] = { x: round( pos.x, 3 ), y: round( pos.y, 3 ) };
1365    }
1366
1367    let strPropName = "TRAINING_TREE_LAYOUT_GLOBAL";
1368    if ( strMyPerms )
1369    {
1370    	strPropName = "TRAINING_TREE_LAYOUT_EDITIONPERMS";
1371	mapCachedPositions = { [strMyPerms]: mapCachedPositions };				// our new subtree
1372    }
1373
1374    const strValue = JSON.stringify( mapCachedPositions );
1375
1376    ajaxUpdateProperty( strPropName, strValue, function() {					// the prop name is parsed on the server
1377	if ( ! strMyPerms )
1378	{
1379	    setGlobalPref( "global-pref-traininglayout-global", strValue );
1380	    console_log( "global tree layout successfully cached: " );
1381	}
1382
1383	else
1384	{
1385	    // overlay the existing cached perms with the newly computed ones
1386	    const strLayout = getEditionProp( "edition-pref-traininglayout-perms" );
1387	    const mapLayout = strLayout ? JSON.parse( strLayout ) : {};
1388
1389	    mapLayout[strMyPerms] = mapCachedPositions[strMyPerms];					// our new subtree
1390
1391	    setEditionProp( "edition-pref-traininglayout-perms", JSON.stringify( mapLayout ) );		// save the subtree
1392	    console_log( "edition+perms tree layout successfully cached: " + strMyPerms );
1393	}
1394    } );
1395}
1396
1397// handle a URL that includes "?training=(.+)"
1398/* exported initializeTraining */
1399function initializeTraining( reqID )
1400{
1401    const req = getRequirement( reqID );
1402    if ( req )
1403    {
1404	$( "#training-filter-input" ).val( reqID.replace( /training0[.]/, "" ) );		// set this, so that filterTraining will include the skill
1405
1406	resetUpPoint( "#help" );
1407	gotoTrainingPage( true, false, false );
1408
1409	if ( ! isTrainingSkillApplicable( req, getLoginID() ) )		// if we can't naturally see the skill (e.g., we don't have the perm), we need to show them all
1410	{
1411	    toggleShowRecommendedTraining( false, false );
1412	    toggleShowUnattainableTraining( true );
1413	}
1414
1415	gotoTrainingSkill( reqID, true );
1416    }
1417}
1418
1419/* exported gotoTrainingLeaderBoard */
1420function gotoTrainingLeaderBoard( bNavigate )
1421{
1422    const table = $( "#training-username-leaderboard" );
1423    table.empty();
1424
1425    // add the header row
1426    table.append( `<tr><th>Name</th><th>Member</th><th>Skills</th><th>Stage</th><th class="last">Max Stage<span class="footnote">*</span></th></tr>` );
1427
1428    const setRequirementIDs = getTrainingSkills_all();
1429    const setProcessedUsernameIDs = new Set();
1430
1431    const mapTotals = {};
1432    for ( const bLeadersOnly of [ true, false ] )		// first we do just the leader logins, then we do everyone else
1433    {
1434	for ( const loginID in g_dbTables['Logins'] )
1435	{
1436	    const login = getLogin( loginID );
1437	    if ( ! login || ! login.active )
1438		continue;
1439
1440	    if ( bLeadersOnly != (login.role == "v") )
1441	        continue;
1442
1443	    if ( login.role == "v" )
1444	    {
1445		const leader = getBestLeader( loginID );
1446		if ( ! isActiveMember( leader ) )
1447		    continue;
1448	    }
1449
1450	    const usernameID = login.username_id;
1451	    if ( setProcessedUsernameIDs.has( usernameID ) )	// did we already process this username as a leader
1452	        continue;
1453
1454	    let nLoginTotal = 0;
1455	    for ( const reqID of setRequirementIDs )
1456		if ( isTrainingComplete_username( reqID, usernameID, loginID ) )
1457		    nLoginTotal++;
1458
1459	    const nMaxStage = getMaxTrainingStage_login( loginID );
1460	    mapTotals[loginID] = 10 * nLoginTotal + nMaxStage;	// we actually store #skills and the max stage to bias all the scouters to the top
1461	    setProcessedUsernameIDs.add( usernameID );
1462	}
1463    }
1464
1465    const sortedIDs = sortTableKeysByIntField( mapTotals );
1466    sortedIDs.reverse();					// want scores to be ranked by descending value
1467
1468    let isEven = false;
1469    for ( let i = 0; i < sortedIDs.length; i++ )
1470    {
1471	const loginID = sortedIDs[i];
1472	const login = getLogin( loginID );
1473
1474	const nTotal = parseInt( mapTotals[loginID] / 10 );
1475	const nStage = getTrainingStage_login( loginID );
1476	const nMaxStage = mapTotals[loginID] % 10;
1477
1478	if ( nTotal <= 1 && nMaxStage <= 2 )			// is this a parent/youth who has never signed in? (Note, the root node is always true, so treat nTotal==1 as uninteresting
1479	    continue;
1480
1481	let strName = "-----";
1482	if ( login.role == "v" )
1483	{
1484	    const leader = getBestLeader( loginID );
1485	    strName = getDisplayName( leader, leader.memberid );
1486	}
1487	else if ( hasPermYouth() )
1488	    strName = getLoginName( loginID )
1489	else
1490	    strName = `--- a non-${getVocab( "Leader" )} ---`;		// be vague
1491
1492	let classMember = "";
1493
1494	if ( loginID == getLoginID() )
1495	{
1496	    strName = "You";
1497	    classMember += " self ";
1498	}
1499
1500	if ( isEven )
1501	    classMember += " even ";
1502	if ( nTotal == 0 )
1503	    classMember += " none ";
1504
1505	isEven = ! isEven;
1506
1507	table.append( `<tr><td class="${classMember}" style="text-align:center;">${i + 1}</td><td class="${classMember}">${strName}</td><td class="${classMember} num">${nTotal}</td><td class="${classMember} num">${nStage}</td><td class="${classMember} num last">${nMaxStage}</td></tr>` );
1508    }
1509
1510    if ( bNavigate )
1511	addPage( "#training-leaderboard" );
1512}
1513
1514/* exported addTrainingInstructions */
1515function addTrainingInstructions( req, strDescription )
1516{
1517    let strUnattainableClass = "";
1518
1519    if ( req.autocompletion != "true" )			// always show the instructions, except for the root node
1520    {
1521	const strInstructionPrefix = `<span class="training-instruction-label">How to demonstrate this skill</span>`;
1522	if ( req.extra.training.editions && ! req.extra.training.editions.includes( EDITION ) )
1523	{
1524	    const strEditions = joinAnd( req.extra.training.editions.map( function ( strEdition ) { const jsonEdition = getEditionInfo( strEdition ); return `<b>${jsonEdition.edition == jsonEdition.parent ? getVocab( "Committee" ) : jsonEdition.moniker}</b>`; } ) );
1525	    strDescription += `<p class="training-instruction" style="color:var(--color-warning-medium);">This skill is only applicable to the ${strEditions} ${req.extra.training.editions.length == 1 ? "edition" : "editions"} of ${getVocab( "PRODUCT_PLAIN" )}.`;
1526	    strUnattainableClass = " unattainable ";
1527	}
1528	else if ( req.extra.training.permission && ! hasPermX( req.extra.training.permission ) )
1529	{
1530	    strDescription += `<p class="training-instruction" style="color:var(--color-warning-medium);">This skill requires that you have the "<b>${getPermDescription( req.extra.training.permission )}</b>" login permission, which can only be granted by one of your account's administrators.`;
1531	    strUnattainableClass = " unattainable ";
1532	}
1533	else if ( req.description.match( /<instruction>(.+)<\/instruction>/s ) )
1534	{
1535	    //let strInstruction = convertCreoleToHTML( RegExp.$1 );
1536	    const strInstruction = RegExp.$1.replace( /\/\/([^\/]+)\/\//g, "<em>$1</em>" ).replace( /[*]{2}([^*]+)[*]{2}/g, "<strong>$1</strong>" );
1537	    strDescription += `<p class="training-instruction">${strInstructionPrefix}${strInstruction.replace( /<name([^>]*)>/g, "&lt;name$1&gt;" ).replace( /\n\n/g, "<p class='training-instruction'>" )}`;
1538	    if ( req.extra.training.status == "tbd" )
1539		strDescription += `<p class="training-instruction" style="padding:0.125em 0;"><span style="font-weight:var(--font-weight-normal); padding: 0 0.25em 0.125em; font-style: italic; border: solid 1px var(--color-warning-medium); color:var(--color-warning-medium)">Sorry! This requirement cannot be demonstrated until the next patch.</span>`;
1540	}
1541	else if ( req.autocompletion )
1542	{
1543	    console_error( "malformed training skill: " + req.requirementid );
1544	    strDescription += `<p class="training-instruction">${strInstructionPrefix}This skill will be automatically marked as complete when you follow its instructions.`;
1545	}
1546	else
1547	    strDescription += `<p class="training-instruction">${strInstructionPrefix}Tick the checkbox above, when you feel you understand this skill.`;
1548    }
1549
1550    return [ strDescription, strUnattainableClass ];
1551}
1552
1553// Debug function to force a reoptimization of the tree (typically manually run from console)
1554/* exported resetTrainingTreeLayout */
1555function resetTrainingTreeLayout( isAllNodes )
1556{
1557    if ( isAllNodes )
1558    {
1559	setGlobalPref( "global-pref-traininglayout-global", null );
1560	$( "#training-canvas" ).removeAttr( "data-built-allnodes" );	// force a rebuild the next time we visit the page
1561    }
1562
1563    setEditionProp( "edition-pref-traininglayout-perms", null );
1564    $( "#training-canvas" ).removeAttr( "data-built-subtree" );	// force a rebuild the next time we visit the page
1565}

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.