[{"data":1,"prerenderedAt":1457},["ShallowReactive",2],{"doc:\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fanimate-with-temporal-controller-pyqgis":3},{"id":4,"title":5,"body":6,"description":1446,"extension":1447,"meta":1448,"navigation":267,"path":1453,"seo":1454,"stem":1455,"__hash__":1456},"docs\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fanimate-with-temporal-controller-pyqgis\u002Findex.md","Animate Layers with the Temporal Controller in PyQGIS",{"type":7,"value":8,"toc":1433},"minimark",[9,13,22,31,193,198,217,221,388,418,421,475,483,487,490,538,555,598,614,618,725,778,799,803,806,1072,1090,1094,1097,1100,1103,1262,1267,1270,1274,1297,1301,1346,1350,1362,1366,1372,1385,1391,1397,1401,1429],[10,11,5],"h1",{"id":12},"animate-layers-with-the-temporal-controller-in-pyqgis",[14,15,16,17,21],"p",{},"The temporal controller is the object that decides ",[18,19,20],"em",{},"when"," the map is. It holds an overall extent, a frame duration, a current frame, and it emits a signal every time that frame changes so that everything else — the canvas, a title label, a chart in a dock — can follow along. Layers do the filtering; the controller does the timekeeping.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002F","Temporal & 3D Visualization in PyQGIS",". It covers reaching the controller, setting extents and frame duration, stepping versus playing, connecting to the frame signal, and driving the animation from a plugin.",[14,32,33],{},[34,35,40,44,48,55,72,81,91,97,103,107,111,118,124,127,131,135,141,145,150,154,158,161,164,171,176,181,189],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 316","img","The controller's state and how a frame number becomes a date range that layers filter against","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"Frame number in, date range out",[45,46,47],"desc",{},"The controller holds an overall temporal extent and a frame duration. Dividing one by the other gives the total frame count. The current frame number is multiplied by the duration and added to the extent's start to give the range for that frame, which every time-aware layer then filters against.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","316","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"tcaFlowArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The controller does arithmetic; the layers do the filtering",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"24","46","212","112","10","#eef7f4","#0f766e","stroke-width:2.5",[73,92,96],{"x":93,"y":94,"style":95,"fill":89,"textAnchor":79},"130","72","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","controller state",[73,98,102],{"x":99,"y":100,"style":101,"fill":71},"44","100","font-size:10px;font-family:monospace","extents: 1 Jun → 8 Jun",[73,104,106],{"x":99,"y":105,"style":101,"fill":71},"124","frameDuration: 1 hour",[73,108,110],{"x":99,"y":109,"style":101,"fill":71},"148","currentFrame: 37",[112,113],"line",{"x1":114,"y1":115,"x2":116,"y2":115,"stroke":71,"style":117},"236","102","262","stroke-width:2;marker-end:url(#tcaFlowArrow)",[49,119],{"x":120,"y":84,"width":121,"height":86,"rx":87,"fill":122,"stroke":123,"style":90},"270","220","#eff3ff","#2563eb",[73,125,126],{"x":75,"y":94,"style":95,"fill":123,"textAnchor":79},"computed range",[73,128,130],{"x":129,"y":115,"style":101,"fill":71},"290","2 Jun 13:00",[73,132,134],{"x":129,"y":133,"style":101,"fill":71},"126","→ 2 Jun 14:00",[73,136,140],{"x":129,"y":137,"style":138,"fill":139},"150","font-size:10px;font-family:sans-serif","#59645f","start + 37 × duration",[112,142],{"x1":143,"y1":115,"x2":144,"y2":115,"stroke":71,"style":117},"490","516",[49,146],{"x":147,"y":84,"width":85,"height":86,"rx":87,"fill":148,"stroke":149,"style":90},"524","#edf8e9","#15803d",[73,151,153],{"x":152,"y":94,"style":95,"fill":149,"textAnchor":79},"630","each layer filters",[73,155,157],{"x":156,"y":100,"style":138,"fill":71},"544","by its own mode",[73,159,160],{"x":156,"y":105,"style":138,"fill":71},"and its own fields",[73,162,163],{"x":156,"y":109,"style":138,"fill":139},"then the canvas redraws",[49,165],{"x":83,"y":166,"width":167,"height":168,"rx":87,"fill":169,"stroke":139,"style":170},"182","712","70","#fffdf7","stroke-width:2",[73,172,175],{"x":75,"y":173,"style":174,"fill":78,"textAnchor":79},"208","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","totalFrameCount = extent length ÷ frame duration",[73,177,180],{"x":75,"y":178,"style":179,"fill":71,"textAnchor":79},"234","text-anchor:middle;font-size:10.5px;font-family:sans-serif","7 days at 1 hour = 168 frames · 7 days at 1 minute = 10,080 frames",[49,182],{"x":183,"y":184,"width":185,"height":186,"rx":63,"fill":187,"stroke":188,"style":170},"120","272","520","34","#fdf2e2","#b45309",[73,190,192],{"x":75,"y":191,"style":179,"fill":71,"textAnchor":79},"294","check the count before you press play — it is easy to ask for ten thousand renders",[194,195,197],"h2",{"id":196},"prerequisites","Prerequisites",[199,200,201,209],"ul",{},[202,203,204,208],"li",{},[205,206,207],"strong",{},"QGIS 3.34 LTR"," or newer, running with a GUI — the controller belongs to the map canvas.",[202,210,211,212,216],{},"At least one layer with temporal properties configured — see ",[26,213,215],{"href":214},"\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fset-layer-temporal-properties-pyqgis\u002F","setting layer temporal properties",".",[194,218,220],{"id":219},"reaching-the-controller-and-setting-it-up","Reaching the controller and setting it up",[222,223,228],"pre",{"className":224,"code":225,"language":226,"meta":227,"style":227},"language-python shiki shiki-themes github-dark","from qgis.core import QgsDateTimeRange, QgsInterval, Qgis\nfrom qgis.PyQt.QtCore import QDateTime, QDate, QTime\n\ncontroller = iface.mapCanvas().temporalController()\n\ncontroller.setTemporalExtents(QgsDateTimeRange(\n    QDateTime(QDate(2026, 6, 1), QTime(0, 0)),\n    QDateTime(QDate(2026, 6, 8), QTime(0, 0)),\n))\ncontroller.setFrameDuration(QgsInterval(1, Qgis.TemporalUnit.Hours))\n\nprint(controller.totalFrameCount(), \"frames\")\n","python","",[229,230,231,249,262,269,281,286,292,325,350,356,367,372],"code",{"__ignoreMap":227},[232,233,235,239,243,246],"span",{"class":112,"line":234},1,[232,236,238],{"class":237},"snl16","from",[232,240,242],{"class":241},"s95oV"," qgis.core ",[232,244,245],{"class":237},"import",[232,247,248],{"class":241}," QgsDateTimeRange, QgsInterval, Qgis\n",[232,250,252,254,257,259],{"class":112,"line":251},2,[232,253,238],{"class":237},[232,255,256],{"class":241}," qgis.PyQt.QtCore ",[232,258,245],{"class":237},[232,260,261],{"class":241}," QDateTime, QDate, QTime\n",[232,263,265],{"class":112,"line":264},3,[232,266,268],{"emptyLinePlaceholder":267},true,"\n",[232,270,272,275,278],{"class":112,"line":271},4,[232,273,274],{"class":241},"controller ",[232,276,277],{"class":237},"=",[232,279,280],{"class":241}," iface.mapCanvas().temporalController()\n",[232,282,284],{"class":112,"line":283},5,[232,285,268],{"emptyLinePlaceholder":267},[232,287,289],{"class":112,"line":288},6,[232,290,291],{"class":241},"controller.setTemporalExtents(QgsDateTimeRange(\n",[232,293,295,298,302,305,308,310,313,316,318,320,322],{"class":112,"line":294},7,[232,296,297],{"class":241},"    QDateTime(QDate(",[232,299,301],{"class":300},"sDLfK","2026",[232,303,304],{"class":241},", ",[232,306,307],{"class":300},"6",[232,309,304],{"class":241},[232,311,312],{"class":300},"1",[232,314,315],{"class":241},"), QTime(",[232,317,51],{"class":300},[232,319,304],{"class":241},[232,321,51],{"class":300},[232,323,324],{"class":241},")),\n",[232,326,328,330,332,334,336,338,340,342,344,346,348],{"class":112,"line":327},8,[232,329,297],{"class":241},[232,331,301],{"class":300},[232,333,304],{"class":241},[232,335,307],{"class":300},[232,337,304],{"class":241},[232,339,63],{"class":300},[232,341,315],{"class":241},[232,343,51],{"class":300},[232,345,304],{"class":241},[232,347,51],{"class":300},[232,349,324],{"class":241},[232,351,353],{"class":112,"line":352},9,[232,354,355],{"class":241},"))\n",[232,357,359,362,364],{"class":112,"line":358},10,[232,360,361],{"class":241},"controller.setFrameDuration(QgsInterval(",[232,363,312],{"class":300},[232,365,366],{"class":241},", Qgis.TemporalUnit.Hours))\n",[232,368,370],{"class":112,"line":369},11,[232,371,268],{"emptyLinePlaceholder":267},[232,373,375,378,381,385],{"class":112,"line":374},12,[232,376,377],{"class":300},"print",[232,379,380],{"class":241},"(controller.totalFrameCount(), ",[232,382,384],{"class":383},"sU2Wk","\"frames\"",[232,386,387],{"class":241},")\n",[14,389,390,393,394,397,398,401,402,405,406,409,410,413,414,417],{},[205,391,392],{},"Breakdown:"," ",[229,395,396],{},"temporalController()"," returns the canvas's own ",[229,399,400],{},"QgsTemporalNavigationObject",", which is shared with the Temporal Controller panel — set it from Python and the panel updates, and vice versa. ",[229,403,404],{},"QgsInterval"," takes a magnitude and a unit; on QGIS 3.28 and earlier the unit enum is ",[229,407,408],{},"QgsUnitTypes.TemporalHours"," rather than ",[229,411,412],{},"Qgis.TemporalUnit.Hours",". ",[229,415,416],{},"totalFrameCount()"," is derived, not stored, so it changes the moment either input does — printing it before doing anything else is the cheap way to notice you have asked for ten thousand frames.",[14,419,420],{},"Deriving the extent from the data, rather than hard-coding it, is almost always what you want:",[222,422,424],{"className":224,"code":423,"language":226,"meta":227,"style":227},"layer = iface.activeLayer()\nextent = layer.temporalProperties().calculateTemporalExtent(layer)\nif extent.isEmpty():\n    raise SystemExit(\"layer has no usable temporal extent\")\ncontroller.setTemporalExtents(extent)\n",[229,425,426,436,446,454,470],{"__ignoreMap":227},[232,427,428,431,433],{"class":112,"line":234},[232,429,430],{"class":241},"layer ",[232,432,277],{"class":237},[232,434,435],{"class":241}," iface.activeLayer()\n",[232,437,438,441,443],{"class":112,"line":251},[232,439,440],{"class":241},"extent ",[232,442,277],{"class":237},[232,444,445],{"class":241}," layer.temporalProperties().calculateTemporalExtent(layer)\n",[232,447,448,451],{"class":112,"line":264},[232,449,450],{"class":237},"if",[232,452,453],{"class":241}," extent.isEmpty():\n",[232,455,456,459,462,465,468],{"class":112,"line":271},[232,457,458],{"class":237},"    raise",[232,460,461],{"class":300}," SystemExit",[232,463,464],{"class":241},"(",[232,466,467],{"class":383},"\"layer has no usable temporal extent\"",[232,469,387],{"class":241},[232,471,472],{"class":112,"line":283},[232,473,474],{"class":241},"controller.setTemporalExtents(extent)\n",[14,476,477,393,479,482],{},[205,478,392],{},[229,480,481],{},"calculateTemporalExtent"," scans the layer's time fields and returns the range they span, so the animation covers exactly the data and no more. It is a full scan, so on a large layer it is worth doing once and caching rather than on every configuration change. The empty check matters because an empty extent gives a frame count of zero and an animation that does nothing, with no error.",[194,484,486],{"id":485},"stepping-versus-playing","Stepping versus playing",[14,488,489],{},"Two very different modes, and scripts almost always want the first.",[222,491,493],{"className":224,"code":492,"language":226,"meta":227,"style":227},"controller.setCurrentFrameNumber(0)\n\nfor frame in range(controller.totalFrameCount()):\n    controller.setCurrentFrameNumber(frame)\n    print(frame, controller.dateTimeRangeForFrameNumber(frame).begin())\n",[229,494,495,504,508,525,530],{"__ignoreMap":227},[232,496,497,500,502],{"class":112,"line":234},[232,498,499],{"class":241},"controller.setCurrentFrameNumber(",[232,501,51],{"class":300},[232,503,387],{"class":241},[232,505,506],{"class":112,"line":251},[232,507,268],{"emptyLinePlaceholder":267},[232,509,510,513,516,519,522],{"class":112,"line":264},[232,511,512],{"class":237},"for",[232,514,515],{"class":241}," frame ",[232,517,518],{"class":237},"in",[232,520,521],{"class":300}," range",[232,523,524],{"class":241},"(controller.totalFrameCount()):\n",[232,526,527],{"class":112,"line":271},[232,528,529],{"class":241},"    controller.setCurrentFrameNumber(frame)\n",[232,531,532,535],{"class":112,"line":283},[232,533,534],{"class":300},"    print",[232,536,537],{"class":241},"(frame, controller.dateTimeRangeForFrameNumber(frame).begin())\n",[14,539,540,393,542,545,546,549,550,554],{},[205,541,392],{},[229,543,544],{},"setCurrentFrameNumber"," sets the frame and triggers the canvas to refresh, but it does ",[205,547,548],{},"not"," block until the refresh completes — the render is asynchronous, so a loop like this races ahead and the canvas only ever shows the last frame. That is fine when you are stepping to inspect state, and fatal when you are capturing images, which is why ",[26,551,553],{"href":552},"\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fexport-temporal-animation-frames-pyqgis\u002F","exporting animation frames"," renders through a job object instead of through the canvas.",[222,556,558],{"className":224,"code":557,"language":226,"meta":227,"style":227},"controller.setNavigationMode(\n    Qgis.TemporalNavigationMode.Animated\n)\ncontroller.setFramesPerSecond(8)\ncontroller.setLooping(True)\ncontroller.play()\n",[229,559,560,565,570,574,583,593],{"__ignoreMap":227},[232,561,562],{"class":112,"line":234},[232,563,564],{"class":241},"controller.setNavigationMode(\n",[232,566,567],{"class":112,"line":251},[232,568,569],{"class":241},"    Qgis.TemporalNavigationMode.Animated\n",[232,571,572],{"class":112,"line":264},[232,573,387],{"class":241},[232,575,576,579,581],{"class":112,"line":271},[232,577,578],{"class":241},"controller.setFramesPerSecond(",[232,580,63],{"class":300},[232,582,387],{"class":241},[232,584,585,588,591],{"class":112,"line":283},[232,586,587],{"class":241},"controller.setLooping(",[232,589,590],{"class":300},"True",[232,592,387],{"class":241},[232,594,595],{"class":112,"line":288},[232,596,597],{"class":241},"controller.play()\n",[14,599,600,393,602,605,606,609,610,613],{},[205,601,392],{},[229,603,604],{},"play()"," starts a timer and returns immediately, so it is only useful in an interactive session or a plugin — a script that calls it and then exits kills the animation with the interpreter. ",[229,607,608],{},"setFramesPerSecond"," is a request rather than a guarantee: if a frame takes longer than the interval to render, the animation simply runs slower, which is the sane behaviour and means a heavy project animates at whatever speed it can manage. ",[229,611,612],{},"setLooping(True)"," restarts at the beginning rather than stopping at the end.",[194,615,617],{"id":616},"reacting-to-the-frame-changing","Reacting to the frame changing",[14,619,620],{},[34,621,624,627,630,633,640,643,648,651,656,661,664,667,670,675,681,685,689,692,695,698,701,705,707,710,714,716,721],{"viewBox":622,"role":37,"ariaLabel":623,"xmlns":39},"0 0 760 300","The updateTemporalRange signal fanning out from the controller to the canvas, a title label, a chart and a plugin panel",[41,625,626],{},"One signal, many followers",[45,628,629],{},"When the controller's frame changes it emits updateTemporalRange carrying the new range. The canvas redraws, but anything else that should follow the animation — a date label, a chart cursor, a plugin panel — has to connect to that signal itself.",[49,631],{"x":51,"y":51,"width":52,"height":632,"fill":54},"300",[56,634,635],{},[59,636,638],{"id":637,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"tcaSigArrow",[68,639],{"d":70,"fill":71},[73,641,642],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"Only the canvas follows automatically",[49,644],{"x":645,"y":84,"width":646,"height":647,"rx":63,"fill":88,"stroke":89,"style":90},"280","200","56",[73,649,650],{"x":75,"y":168,"style":95,"fill":89,"textAnchor":79},"controller",[73,652,655],{"x":75,"y":653,"style":654,"fill":71,"textAnchor":79},"90","text-anchor:middle;font-size:10px;font-family:monospace","updateTemporalRange",[68,657],{"d":658,"fill":659,"stroke":71,"style":660},"M380 102 L380 126 L110 126 L110 152","none","stroke-width:2;marker-end:url(#tcaSigArrow)",[68,662],{"d":663,"fill":659,"stroke":71,"style":660},"M380 102 L380 126 L290 126 L290 152",[68,665],{"d":666,"fill":659,"stroke":71,"style":660},"M380 102 L380 126 L470 126 L470 152",[68,668],{"d":669,"fill":659,"stroke":71,"style":660},"M380 102 L380 126 L650 126 L650 152",[49,671],{"x":83,"y":672,"width":673,"height":674,"rx":63,"fill":148,"stroke":149,"style":90},"158","172","86",[73,676,680],{"x":677,"y":678,"style":679,"fill":149,"textAnchor":79},"110","184","text-anchor:middle;font-size:10.5px;font-weight:bold;font-family:sans-serif","map canvas",[73,682,684],{"x":677,"y":173,"style":683,"fill":71,"textAnchor":79},"text-anchor:middle;font-size:10px;font-family:sans-serif","connected already",[73,686,688],{"x":677,"y":687,"style":683,"fill":139,"textAnchor":79},"230","redraws itself",[49,690],{"x":691,"y":672,"width":673,"height":674,"rx":63,"fill":169,"stroke":188,"style":90},"204",[73,693,694],{"x":129,"y":678,"style":679,"fill":188,"textAnchor":79},"date label",[73,696,697],{"x":129,"y":173,"style":683,"fill":71,"textAnchor":79},"you connect it",[49,699],{"x":700,"y":672,"width":673,"height":674,"rx":63,"fill":169,"stroke":188,"style":90},"384",[73,702,704],{"x":703,"y":678,"style":679,"fill":188,"textAnchor":79},"470","chart cursor",[73,706,697],{"x":703,"y":173,"style":683,"fill":71,"textAnchor":79},[49,708],{"x":709,"y":672,"width":673,"height":674,"rx":63,"fill":169,"stroke":188,"style":90},"564",[73,711,713],{"x":712,"y":678,"style":679,"fill":188,"textAnchor":79},"650","plugin panel",[73,715,697],{"x":712,"y":173,"style":683,"fill":71,"textAnchor":79},[49,717],{"x":183,"y":718,"width":185,"height":719,"rx":307,"fill":187,"stroke":188,"style":720},"264","28","stroke-width:1.8",[73,722,724],{"x":75,"y":723,"style":179,"fill":71,"textAnchor":79},"283","disconnect on plugin unload or the slot outlives its widget",[222,726,728],{"className":224,"code":727,"language":226,"meta":227,"style":227},"def on_range_changed(temporal_range):\n    begin = temporal_range.begin()\n    print(\"now showing\", begin.toString(\"yyyy-MM-dd HH:mm\"))\n\ncontroller.updateTemporalRange.connect(on_range_changed)\n",[229,729,730,742,752,769,773],{"__ignoreMap":227},[232,731,732,735,739],{"class":112,"line":234},[232,733,734],{"class":237},"def",[232,736,738],{"class":737},"svObZ"," on_range_changed",[232,740,741],{"class":241},"(temporal_range):\n",[232,743,744,747,749],{"class":112,"line":251},[232,745,746],{"class":241},"    begin ",[232,748,277],{"class":237},[232,750,751],{"class":241}," temporal_range.begin()\n",[232,753,754,756,758,761,764,767],{"class":112,"line":264},[232,755,534],{"class":300},[232,757,464],{"class":241},[232,759,760],{"class":383},"\"now showing\"",[232,762,763],{"class":241},", begin.toString(",[232,765,766],{"class":383},"\"yyyy-MM-dd HH:mm\"",[232,768,355],{"class":241},[232,770,771],{"class":112,"line":271},[232,772,268],{"emptyLinePlaceholder":267},[232,774,775],{"class":112,"line":283},[232,776,777],{"class":241},"controller.updateTemporalRange.connect(on_range_changed)\n",[14,779,780,782,783,786,787,790,791,794,795,216],{},[205,781,392],{}," The signal carries the new ",[229,784,785],{},"QgsDateTimeRange",", so the slot needs no lookups. It fires on every frame change from any source — the panel's play button, a script call, a keyboard step — which is exactly what makes it the right place to keep a label or a chart in sync. In a plugin, connect in ",[229,788,789],{},"initGui"," and disconnect in ",[229,792,793],{},"unload","; a slot bound to a deleted widget is the classic cause of a crash on plugin reload, and the mechanics of that are covered in ",[26,796,798],{"href":797},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis\u002F","understanding signals and slots",[194,800,802],{"id":801},"driving-it-from-a-plugin","Driving it from a plugin",[14,804,805],{},"A plugin that owns an animation usually wants to configure the controller once and then step it in response to its own controls.",[222,807,809],{"className":224,"code":808,"language":226,"meta":227,"style":227},"from qgis.PyQt.QtCore import QTimer\n\nclass FloodAnimator:\n    def __init__(self, canvas):\n        self.controller = canvas.temporalController()\n        self.controller.updateTemporalRange.connect(self.on_range)\n        self.timer = QTimer()\n        self.timer.timeout.connect(self.advance)\n\n    def start(self, interval_ms=200):\n        self.controller.setCurrentFrameNumber(0)\n        self.timer.start(interval_ms)\n\n    def advance(self):\n        frame = self.controller.currentFrameNumber() + 1\n        if frame >= self.controller.totalFrameCount():\n            self.timer.stop()\n            return\n        self.controller.setCurrentFrameNumber(frame)\n\n    def on_range(self, temporal_range):\n        pass  # update the plugin's own labels here\n\n    def teardown(self):\n        self.timer.stop()\n        self.controller.updateTemporalRange.disconnect(self.on_range)\n",[229,810,811,822,826,837,848,861,874,886,898,902,919,930,937,942,953,973,989,998,1004,1012,1017,1028,1038,1043,1053,1060],{"__ignoreMap":227},[232,812,813,815,817,819],{"class":112,"line":234},[232,814,238],{"class":237},[232,816,256],{"class":241},[232,818,245],{"class":237},[232,820,821],{"class":241}," QTimer\n",[232,823,824],{"class":112,"line":251},[232,825,268],{"emptyLinePlaceholder":267},[232,827,828,831,834],{"class":112,"line":264},[232,829,830],{"class":237},"class",[232,832,833],{"class":737}," FloodAnimator",[232,835,836],{"class":241},":\n",[232,838,839,842,845],{"class":112,"line":271},[232,840,841],{"class":237},"    def",[232,843,844],{"class":300}," __init__",[232,846,847],{"class":241},"(self, canvas):\n",[232,849,850,853,856,858],{"class":112,"line":283},[232,851,852],{"class":300},"        self",[232,854,855],{"class":241},".controller ",[232,857,277],{"class":237},[232,859,860],{"class":241}," canvas.temporalController()\n",[232,862,863,865,868,871],{"class":112,"line":288},[232,864,852],{"class":300},[232,866,867],{"class":241},".controller.updateTemporalRange.connect(",[232,869,870],{"class":300},"self",[232,872,873],{"class":241},".on_range)\n",[232,875,876,878,881,883],{"class":112,"line":294},[232,877,852],{"class":300},[232,879,880],{"class":241},".timer ",[232,882,277],{"class":237},[232,884,885],{"class":241}," QTimer()\n",[232,887,888,890,893,895],{"class":112,"line":327},[232,889,852],{"class":300},[232,891,892],{"class":241},".timer.timeout.connect(",[232,894,870],{"class":300},[232,896,897],{"class":241},".advance)\n",[232,899,900],{"class":112,"line":352},[232,901,268],{"emptyLinePlaceholder":267},[232,903,904,906,909,912,914,916],{"class":112,"line":358},[232,905,841],{"class":237},[232,907,908],{"class":737}," start",[232,910,911],{"class":241},"(self, interval_ms",[232,913,277],{"class":237},[232,915,646],{"class":300},[232,917,918],{"class":241},"):\n",[232,920,921,923,926,928],{"class":112,"line":369},[232,922,852],{"class":300},[232,924,925],{"class":241},".controller.setCurrentFrameNumber(",[232,927,51],{"class":300},[232,929,387],{"class":241},[232,931,932,934],{"class":112,"line":374},[232,933,852],{"class":300},[232,935,936],{"class":241},".timer.start(interval_ms)\n",[232,938,940],{"class":112,"line":939},13,[232,941,268],{"emptyLinePlaceholder":267},[232,943,945,947,950],{"class":112,"line":944},14,[232,946,841],{"class":237},[232,948,949],{"class":737}," advance",[232,951,952],{"class":241},"(self):\n",[232,954,956,959,961,964,967,970],{"class":112,"line":955},15,[232,957,958],{"class":241},"        frame ",[232,960,277],{"class":237},[232,962,963],{"class":300}," self",[232,965,966],{"class":241},".controller.currentFrameNumber() ",[232,968,969],{"class":237},"+",[232,971,972],{"class":300}," 1\n",[232,974,976,979,981,984,986],{"class":112,"line":975},16,[232,977,978],{"class":237},"        if",[232,980,515],{"class":241},[232,982,983],{"class":237},">=",[232,985,963],{"class":300},[232,987,988],{"class":241},".controller.totalFrameCount():\n",[232,990,992,995],{"class":112,"line":991},17,[232,993,994],{"class":300},"            self",[232,996,997],{"class":241},".timer.stop()\n",[232,999,1001],{"class":112,"line":1000},18,[232,1002,1003],{"class":237},"            return\n",[232,1005,1007,1009],{"class":112,"line":1006},19,[232,1008,852],{"class":300},[232,1010,1011],{"class":241},".controller.setCurrentFrameNumber(frame)\n",[232,1013,1015],{"class":112,"line":1014},20,[232,1016,268],{"emptyLinePlaceholder":267},[232,1018,1020,1022,1025],{"class":112,"line":1019},21,[232,1021,841],{"class":237},[232,1023,1024],{"class":737}," on_range",[232,1026,1027],{"class":241},"(self, temporal_range):\n",[232,1029,1031,1034],{"class":112,"line":1030},22,[232,1032,1033],{"class":237},"        pass",[232,1035,1037],{"class":1036},"sjoCn","  # update the plugin's own labels here\n",[232,1039,1041],{"class":112,"line":1040},23,[232,1042,268],{"emptyLinePlaceholder":267},[232,1044,1046,1048,1051],{"class":112,"line":1045},24,[232,1047,841],{"class":237},[232,1049,1050],{"class":737}," teardown",[232,1052,952],{"class":241},[232,1054,1056,1058],{"class":112,"line":1055},25,[232,1057,852],{"class":300},[232,1059,997],{"class":241},[232,1061,1063,1065,1068,1070],{"class":112,"line":1062},26,[232,1064,852],{"class":300},[232,1066,1067],{"class":241},".controller.updateTemporalRange.disconnect(",[232,1069,870],{"class":300},[232,1071,873],{"class":241},[14,1073,1074,1076,1077,409,1080,1082,1083,1085,1086,1089],{},[205,1075,392],{}," Driving with your own ",[229,1078,1079],{},"QTimer",[229,1081,604],{}," gives you the stop condition, the interval and the ability to do work between frames — which the built-in playback does not offer. Keeping a reference to the timer on the instance is not optional: a ",[229,1084,1079],{}," created as a local goes out of scope and stops firing, which is one of the most common and most confusing bugs in PyQGIS plugin code. ",[229,1087,1088],{},"teardown"," disconnecting the signal is what keeps a plugin reloadable.",[194,1091,1093],{"id":1092},"choosing-a-frame-duration","Choosing a frame duration",[14,1095,1096],{},"The frame duration is the decision that shapes the animation, and it is worth thinking about in two directions at once: what the data can support, and what a viewer can absorb.",[14,1098,1099],{},"From the data's side, a frame shorter than the resolution of the underlying observations is invention. Hourly rain gauge readings animated at one-minute frames produce sixty identical frames per reading and an animation that appears to stutter. A frame duration equal to the observation interval is the natural floor.",[14,1101,1102],{},"From the viewer's side, an animation that runs longer than about thirty seconds loses its audience, and one with more than a few hundred frames is expensive to render. Dividing the total span by a target of 150 to 300 frames gives a duration that is usually about right, and rounding it to a human unit — an hour, a day, a week — makes the date labels readable.",[222,1104,1106],{"className":224,"code":1105,"language":226,"meta":227,"style":227},"span_seconds = extent.begin().secsTo(extent.end())\nfor magnitude, unit, seconds in (\n    (1, Qgis.TemporalUnit.Hours, 3600),\n    (6, Qgis.TemporalUnit.Hours, 21600),\n    (1, Qgis.TemporalUnit.Days, 86400),\n    (1, Qgis.TemporalUnit.Weeks, 604800),\n):\n    frames = span_seconds \u002F (magnitude * seconds)\n    print(f\"{magnitude} × {unit}: {frames:.0f} frames\")\n",[229,1107,1108,1118,1130,1146,1159,1173,1187,1191,1213],{"__ignoreMap":227},[232,1109,1110,1113,1115],{"class":112,"line":234},[232,1111,1112],{"class":241},"span_seconds ",[232,1114,277],{"class":237},[232,1116,1117],{"class":241}," extent.begin().secsTo(extent.end())\n",[232,1119,1120,1122,1125,1127],{"class":112,"line":251},[232,1121,512],{"class":237},[232,1123,1124],{"class":241}," magnitude, unit, seconds ",[232,1126,518],{"class":237},[232,1128,1129],{"class":241}," (\n",[232,1131,1132,1135,1137,1140,1143],{"class":112,"line":264},[232,1133,1134],{"class":241},"    (",[232,1136,312],{"class":300},[232,1138,1139],{"class":241},", Qgis.TemporalUnit.Hours, ",[232,1141,1142],{"class":300},"3600",[232,1144,1145],{"class":241},"),\n",[232,1147,1148,1150,1152,1154,1157],{"class":112,"line":271},[232,1149,1134],{"class":241},[232,1151,307],{"class":300},[232,1153,1139],{"class":241},[232,1155,1156],{"class":300},"21600",[232,1158,1145],{"class":241},[232,1160,1161,1163,1165,1168,1171],{"class":112,"line":283},[232,1162,1134],{"class":241},[232,1164,312],{"class":300},[232,1166,1167],{"class":241},", Qgis.TemporalUnit.Days, ",[232,1169,1170],{"class":300},"86400",[232,1172,1145],{"class":241},[232,1174,1175,1177,1179,1182,1185],{"class":112,"line":288},[232,1176,1134],{"class":241},[232,1178,312],{"class":300},[232,1180,1181],{"class":241},", Qgis.TemporalUnit.Weeks, ",[232,1183,1184],{"class":300},"604800",[232,1186,1145],{"class":241},[232,1188,1189],{"class":112,"line":294},[232,1190,918],{"class":241},[232,1192,1193,1196,1198,1201,1204,1207,1210],{"class":112,"line":327},[232,1194,1195],{"class":241},"    frames ",[232,1197,277],{"class":237},[232,1199,1200],{"class":241}," span_seconds ",[232,1202,1203],{"class":237},"\u002F",[232,1205,1206],{"class":241}," (magnitude ",[232,1208,1209],{"class":237},"*",[232,1211,1212],{"class":241}," seconds)\n",[232,1214,1215,1217,1219,1222,1225,1228,1231,1234,1237,1239,1242,1244,1247,1249,1252,1255,1257,1260],{"class":112,"line":352},[232,1216,534],{"class":300},[232,1218,464],{"class":241},[232,1220,1221],{"class":237},"f",[232,1223,1224],{"class":383},"\"",[232,1226,1227],{"class":300},"{",[232,1229,1230],{"class":241},"magnitude",[232,1232,1233],{"class":300},"}",[232,1235,1236],{"class":383}," × ",[232,1238,1227],{"class":300},[232,1240,1241],{"class":241},"unit",[232,1243,1233],{"class":300},[232,1245,1246],{"class":383},": ",[232,1248,1227],{"class":300},[232,1250,1251],{"class":241},"frames",[232,1253,1254],{"class":237},":.0f",[232,1256,1233],{"class":300},[232,1258,1259],{"class":383}," frames\"",[232,1261,387],{"class":241},[14,1263,1264,1266],{},[205,1265,392],{}," Printing the candidates rather than computing one answer respects that this is a judgement rather than an optimisation — a flood model wants hourly frames over three days, and a land-cover change animation wants annual frames over thirty years, and no formula distinguishes them. What the loop does usefully is stop you discovering the frame count after starting a render.",[14,1268,1269],{},"Where the data is dense but the interesting part is short, two animations beat one compromise: a coarse overview across the whole span and a fine one across the event.",[194,1271,1273],{"id":1272},"qgis-version-compatibility","QGIS version compatibility",[14,1275,1276,1278,1279,1282,1283,1286,1287,1278,1289,1292,1293,1296],{},[229,1277,400],{}," and ",[229,1280,1281],{},"mapCanvas().temporalController()"," have been present since 3.14. The navigation-mode and temporal-unit enums moved into the scoped ",[229,1284,1285],{},"Qgis"," namespace in 3.30, with the old ",[229,1288,408],{},[229,1290,1291],{},"QgsTemporalNavigationObject.Animated"," names still working. ",[229,1294,1295],{},"dateTimeRangeForFrameNumber"," has been stable throughout and is the supported way to convert a frame to a range without changing the current frame.",[194,1298,1300],{"id":1299},"troubleshooting","Troubleshooting",[199,1302,1303,1311,1317,1323,1331,1337],{},[202,1304,1305,1310],{},[205,1306,1307,1309],{},[229,1308,396],{}," returns None."," No GUI — there is no controller in a headless script.",[202,1312,1313,1316],{},[205,1314,1315],{},"The frame count is zero."," The extent is empty or the frame duration is longer than the extent.",[202,1318,1319,1322],{},[205,1320,1321],{},"Only the last frame is drawn in a loop."," Canvas rendering is asynchronous; render through a job object to capture frames.",[202,1324,1325,1330],{},[205,1326,1327,1329],{},[229,1328,604],{}," does nothing in a script."," The interpreter exits before the timer fires. Use an interactive session or a plugin.",[202,1332,1333,1336],{},[205,1334,1335],{},"The animation is far slower than the requested frame rate."," Rendering is the bottleneck; simplify the project or lengthen the frame duration.",[202,1338,1339,1342,1343,1345],{},[205,1340,1341],{},"A crash on plugin reload."," A slot still connected to ",[229,1344,655],{}," after its object was destroyed.",[194,1347,1349],{"id":1348},"conclusion","Conclusion",[14,1351,1352,1353,1355,1356,1358,1359,1361],{},"Set the extent from the data, check the frame count before anything else, step with ",[229,1354,544],{}," when you need control and ",[229,1357,604],{}," only in an interactive context, and connect to ",[229,1360,655],{}," for anything that must follow the animation. The controller is small and predictable; nearly every problem with it is a frame count nobody looked at.",[194,1363,1365],{"id":1364},"frequently-asked-questions","Frequently Asked Questions",[14,1367,1368,1371],{},[205,1369,1370],{},"Can I animate a 3D view with the same controller?","\nYes — a 3D map canvas honours the same temporal range, so a single controller drives both views in step.",[14,1373,1374,1377,1378,1381,1382,1384],{},[205,1375,1376],{},"How do I show the current date on the map?","\nA layout label with the ",[229,1379,1380],{},"@map_start_time"," variable, or a canvas decoration updated from the ",[229,1383,655],{}," slot. The layout route survives an export.",[14,1386,1387,1390],{},[205,1388,1389],{},"Does the controller affect layers whose temporal properties are inactive?","\nNo. An inactive layer draws identically at every frame, which is how you keep a basemap visible throughout.",[14,1392,1393,1396],{},[205,1394,1395],{},"Can the frame duration be irregular?","\nNo. Frames are uniform. Irregular time steps mean rendering per timestamp yourself rather than using the frame model.",[194,1398,1400],{"id":1399},"related","Related",[199,1402,1403,1408,1413,1418,1423],{},[202,1404,1405,1407],{},[26,1406,29],{"href":28}," — the guide this recipe belongs to",[202,1409,1410],{},[26,1411,1412],{"href":214},"Set Layer Temporal Properties in PyQGIS",[202,1414,1415],{},[26,1416,1417],{"href":552},"Export Temporal Animation Frames in PyQGIS",[202,1419,1420],{},[26,1421,1422],{"href":797},"Understand QGIS Signals and Slots in PyQGIS",[202,1424,1425],{},[26,1426,1428],{"href":1427},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Frun-background-task-with-qgstask-pyqgis\u002F","Run a Background Task with QgsTask in PyQGIS",[1430,1431,1432],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}",{"title":227,"searchDepth":251,"depth":251,"links":1434},[1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445],{"id":196,"depth":251,"text":197},{"id":219,"depth":251,"text":220},{"id":485,"depth":251,"text":486},{"id":616,"depth":251,"text":617},{"id":801,"depth":251,"text":802},{"id":1092,"depth":251,"text":1093},{"id":1272,"depth":251,"text":1273},{"id":1299,"depth":251,"text":1300},{"id":1348,"depth":251,"text":1349},{"id":1364,"depth":251,"text":1365},{"id":1399,"depth":251,"text":1400},"Drive the QGIS animation controller from Python — set the extent and frame duration, step or play frames, react to frame changes, and keep the canvas in step with the data.","md",{"slug":1449,"type":1450,"breadcrumb":1451,"datePublished":1452,"dateModified":1452},"animate-with-temporal-controller-pyqgis","article","Animate with the Controller","2026-09-05","\u002Fpyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fanimate-with-temporal-controller-pyqgis",{"title":5,"description":1446},"pyqgis-cartography-visualization\u002Ftemporal-and-3d-visualization\u002Fanimate-with-temporal-controller-pyqgis\u002Findex","34OlH1nXYtVFzNgt8uaQ78sso2EgNgwPDEbGHjzOd-4",1788563847733]