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%;">⇓</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( ", " )}</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, "<name$1>" ).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.